From 5630979adfa01003a8b8d6336e80af8ed12b8c9e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:29:11 -0600 Subject: [PATCH] Admin Page work - Still blank/broken --- BULK-DELETE-ENDPOINT-DOCS.md | 227 +++ .../features/admin/api/admin.controller.ts | 270 ++- .../src/features/admin/api/admin.routes.ts | 31 +- .../features/admin/api/admin.validation.ts | 34 + .../features/admin/api/catalog.controller.ts | 99 + .../src/features/admin/domain/admin.types.ts | 48 + docs/ADMIN-DEPLOYMENT-CHECKLIST.md | 343 ---- docs/ADMIN-IMPLEMENTATION-SUMMARY.md | 440 ----- docs/changes/2024-admin-roadmap.md | 45 - docs/changes/admin-implementation-plan.md | 1218 ++++++++++++ docs/changes/admin-settings-frontend-plan.md | 134 ++ .../admin/__tests__/catalogShared.test.ts | 134 ++ .../components/AdminSectionHeader.test.tsx | 43 + .../components/AdminSkeleton.test.tsx | 39 + .../components/BulkActionDialog.test.tsx | 83 + .../__tests__/components/EmptyState.test.tsx | 59 + .../__tests__/components/ErrorState.test.tsx | 46 + .../components/SelectionToolbar.test.tsx | 63 + .../__tests__/hooks/useBulkSelection.test.ts | 119 ++ frontend/src/features/admin/api/admin.api.ts | 40 +- .../features/admin/catalog/catalogSchemas.ts | 151 ++ .../features/admin/catalog/catalogShared.ts | 157 ++ .../admin/components/AdminDataGrid.tsx | 253 +++ .../admin/components/AdminSectionHeader.tsx | 92 + .../admin/components/AdminSkeleton.tsx | 94 + .../admin/components/AuditLogDrawer.tsx | 224 +++ .../admin/components/AuditLogPanel.tsx | 219 ++ .../admin/components/BulkActionDialog.tsx | 137 ++ .../features/admin/components/EmptyState.tsx | 106 + .../features/admin/components/ErrorState.tsx | 73 + .../admin/components/SelectionToolbar.tsx | 105 + .../src/features/admin/components/index.ts | 30 + .../features/admin/hooks/useAuditLogStream.ts | 140 ++ .../features/admin/hooks/useBulkSelection.ts | 114 ++ .../src/features/admin/hooks/useCatalog.ts | 16 +- .../admin/mobile/AdminCatalogMobileScreen.tsx | 1029 +++++++++- frontend/src/pages/admin/AdminCatalogPage.tsx | 1760 ++++++++++++++++- test-bulk-delete.sh | 82 + 38 files changed, 7373 insertions(+), 924 deletions(-) create mode 100644 BULK-DELETE-ENDPOINT-DOCS.md delete mode 100644 docs/ADMIN-DEPLOYMENT-CHECKLIST.md delete mode 100644 docs/ADMIN-IMPLEMENTATION-SUMMARY.md delete mode 100644 docs/changes/2024-admin-roadmap.md create mode 100644 docs/changes/admin-implementation-plan.md create mode 100644 docs/changes/admin-settings-frontend-plan.md create mode 100644 frontend/src/features/admin/__tests__/catalogShared.test.ts create mode 100644 frontend/src/features/admin/__tests__/components/AdminSectionHeader.test.tsx create mode 100644 frontend/src/features/admin/__tests__/components/AdminSkeleton.test.tsx create mode 100644 frontend/src/features/admin/__tests__/components/BulkActionDialog.test.tsx create mode 100644 frontend/src/features/admin/__tests__/components/EmptyState.test.tsx create mode 100644 frontend/src/features/admin/__tests__/components/ErrorState.test.tsx create mode 100644 frontend/src/features/admin/__tests__/components/SelectionToolbar.test.tsx create mode 100644 frontend/src/features/admin/__tests__/hooks/useBulkSelection.test.ts create mode 100644 frontend/src/features/admin/catalog/catalogSchemas.ts create mode 100644 frontend/src/features/admin/catalog/catalogShared.ts create mode 100644 frontend/src/features/admin/components/AdminDataGrid.tsx create mode 100644 frontend/src/features/admin/components/AdminSectionHeader.tsx create mode 100644 frontend/src/features/admin/components/AdminSkeleton.tsx create mode 100644 frontend/src/features/admin/components/AuditLogDrawer.tsx create mode 100644 frontend/src/features/admin/components/AuditLogPanel.tsx create mode 100644 frontend/src/features/admin/components/BulkActionDialog.tsx create mode 100644 frontend/src/features/admin/components/EmptyState.tsx create mode 100644 frontend/src/features/admin/components/ErrorState.tsx create mode 100644 frontend/src/features/admin/components/SelectionToolbar.tsx create mode 100644 frontend/src/features/admin/components/index.ts create mode 100644 frontend/src/features/admin/hooks/useAuditLogStream.ts create mode 100644 frontend/src/features/admin/hooks/useBulkSelection.ts create mode 100755 test-bulk-delete.sh diff --git a/BULK-DELETE-ENDPOINT-DOCS.md b/BULK-DELETE-ENDPOINT-DOCS.md new file mode 100644 index 0000000..60f66b8 --- /dev/null +++ b/BULK-DELETE-ENDPOINT-DOCS.md @@ -0,0 +1,227 @@ +# Bulk Catalog Delete Endpoint Documentation + +## Overview +Generic bulk delete endpoint for catalog entities (makes, models, years, trims, engines) in the admin panel. + +## Endpoint +``` +DELETE /api/admin/catalog/{entity}/bulk-delete +``` + +## Path Parameters +- `entity`: Entity type - one of: `makes`, `models`, `years`, `trims`, `engines` + +## Request Body +```json +{ + "ids": [1, 2, 3, 4, 5] +} +``` + +### Validation Rules +- IDs must be an array of positive integers +- At least 1 ID required +- Maximum 100 IDs per batch +- All IDs must be valid integers (not strings or floats) + +## Response Codes +- `204 No Content`: All deletions succeeded (no response body) +- `207 Multi-Status`: Some deletions failed (includes response body with details) +- `400 Bad Request`: Invalid entity type or invalid request body +- `401 Unauthorized`: Missing or invalid authentication +- `500 Internal Server Error`: Unexpected server error + +## Response Body (207 Multi-Status only) +```json +{ + "deleted": [1, 3, 5], + "failed": [ + { + "id": 2, + "error": "Make 2 not found" + }, + { + "id": 4, + "error": "Cannot delete make with existing models" + } + ] +} +``` + +## Cascade Behavior +The endpoint uses existing single-delete methods which have the following behavior: + +### Makes +- **Blocks deletion** if models exist under the make +- Error: "Cannot delete make with existing models" +- **Solution**: Delete all dependent models first + +### Models +- **Blocks deletion** if years exist under the model +- Error: "Cannot delete model with existing years" +- **Solution**: Delete all dependent years first + +### Years +- **Blocks deletion** if trims exist under the year +- Error: "Cannot delete year with existing trims" +- **Solution**: Delete all dependent trims first + +### Trims +- **Blocks deletion** if engines exist under the trim +- Error: "Cannot delete trim with existing engines" +- **Solution**: Delete all dependent engines first + +### Engines +- **No cascade restrictions** (leaf entity in hierarchy) + +## Deletion Order for Hierarchy +To delete an entire make and all its dependencies: +1. Delete engines first +2. Delete trims +3. Delete years +4. Delete models +5. Delete make last + +## Examples + +### Example 1: Delete Multiple Engines (Success) +```bash +DELETE /api/admin/catalog/engines/bulk-delete +{ + "ids": [101, 102, 103] +} + +Response: 204 No Content +``` + +### Example 2: Delete Multiple Makes (Partial Failure) +```bash +DELETE /api/admin/catalog/makes/bulk-delete +{ + "ids": [1, 2, 3] +} + +Response: 207 Multi-Status +{ + "deleted": [3], + "failed": [ + { + "id": 1, + "error": "Cannot delete make with existing models" + }, + { + "id": 2, + "error": "Make 2 not found" + } + ] +} +``` + +### Example 3: Invalid Entity Type +```bash +DELETE /api/admin/catalog/invalid/bulk-delete +{ + "ids": [1, 2, 3] +} + +Response: 400 Bad Request +{ + "error": "Invalid entity type", + "message": "Entity must be one of: makes, models, years, trims, engines" +} +``` + +### Example 4: Invalid IDs +```bash +DELETE /api/admin/catalog/makes/bulk-delete +{ + "ids": ["abc", "def"] +} + +Response: 400 Bad Request +{ + "error": "Invalid IDs", + "message": "All IDs must be positive integers" +} +``` + +## Implementation Details + +### Files Modified +1. `/backend/src/features/admin/api/admin.routes.ts` (line 209-212) + - Added route: `DELETE /admin/catalog/:entity/bulk-delete` + - Requires admin authentication + +2. `/backend/src/features/admin/api/catalog.controller.ts` (line 542-638) + - Added method: `bulkDeleteCatalogEntity()` + - Maps entity type to appropriate delete method + - Processes deletions sequentially + - Collects successes and failures + +3. `/backend/src/features/admin/api/admin.validation.ts` (line 43-49, 57-58) + - Added `catalogEntitySchema`: Validates entity type + - Added `bulkDeleteCatalogSchema`: Validates request body + - Exported types: `CatalogEntity`, `BulkDeleteCatalogInput` + +4. `/backend/src/features/admin/domain/admin.types.ts` (line 97-103) + - Added `BulkDeleteCatalogResponse` interface + +### Continue-on-Failure Pattern +The endpoint uses a continue-on-failure pattern: +- One deletion failure does NOT stop the batch +- All deletions are attempted +- Successes and failures are tracked separately +- Final response includes both lists + +### Transaction Behavior +- Each individual deletion runs in its own transaction (via service layer) +- If one delete fails, it doesn't affect others +- No rollback of previously successful deletions + +## Testing + +### Manual Testing with cURL +```bash +# Test valid request (requires auth token) +curl -X DELETE "http://localhost/api/admin/catalog/makes/bulk-delete" \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{"ids": [1, 2, 3]}' + +# Test invalid entity type +curl -X DELETE "http://localhost/api/admin/catalog/invalid/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [1, 2, 3]}' + +# Test empty IDs +curl -X DELETE "http://localhost/api/admin/catalog/makes/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": []}' +``` + +### Expected Audit Log Behavior +Each successful deletion creates a platform change log entry: +- `changeType`: "DELETE" +- `resourceType`: Entity type (makes, models, years, trims, engines) +- `resourceId`: ID of deleted entity +- `changedBy`: Actor's user ID +- `oldValue`: Entity data before deletion +- `newValue`: null + +## Security +- Endpoint requires admin authentication (via `fastify.requireAdmin`) +- Actor ID is logged for all operations +- All deletions are audited in platform_change_log table + +## Performance Considerations +- Deletions are processed sequentially (not in parallel) +- Each deletion queries the database separately +- Cache invalidation occurs after each successful deletion +- For large batches (50+ items), consider breaking into smaller batches + +## Future Enhancements +Potential improvements: +1. Add cascade delete option to automatically delete dependent entities +2. Add dry-run mode to preview what would be deleted +3. Add batch size optimization for better performance +4. Add progress tracking for long-running batches diff --git a/backend/src/features/admin/api/admin.controller.ts b/backend/src/features/admin/api/admin.controller.ts index 1eb657d..7666dee 100644 --- a/backend/src/features/admin/api/admin.controller.ts +++ b/backend/src/features/admin/api/admin.controller.ts @@ -11,13 +11,25 @@ import { logger } from '../../../core/logging/logger'; import { CreateAdminInput, AdminAuth0SubInput, - AuditLogsQueryInput + AuditLogsQueryInput, + BulkCreateAdminInput, + BulkRevokeAdminInput, + BulkReinstateAdminInput } from './admin.validation'; import { createAdminSchema, adminAuth0SubSchema, - auditLogsQuerySchema + auditLogsQuerySchema, + bulkCreateAdminSchema, + bulkRevokeAdminSchema, + bulkReinstateAdminSchema } from './admin.validation'; +import { + BulkCreateAdminResponse, + BulkRevokeAdminResponse, + BulkReinstateAdminResponse, + AdminUser +} from '../domain/admin.types'; export class AdminController { private adminService: AdminService; @@ -398,6 +410,260 @@ export class AdminController { } } + /** + * POST /api/admin/admins/bulk - Create multiple admin users + */ + async bulkCreateAdmins( + request: FastifyRequest<{ Body: BulkCreateAdminInput }>, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing' + }); + } + + // Validate request body + const validation = bulkCreateAdminSchema.safeParse(request.body); + if (!validation.success) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: validation.error.errors + }); + } + + const { admins } = validation.data; + + const created: AdminUser[] = []; + const failed: Array<{ email: string; error: string }> = []; + + // Process each admin creation sequentially to maintain data consistency + for (const adminInput of admins) { + try { + const { email, role = 'admin' } = adminInput; + + // Generate auth0Sub for the new admin + // In production, this should be the actual Auth0 user ID + const auth0Sub = `auth0|${email.replace('@', '_at_')}`; + + const admin = await this.adminService.createAdmin( + email, + role, + auth0Sub, + actorId + ); + + created.push(admin); + } catch (error: any) { + logger.error('Error creating admin in bulk operation', { + error: error.message, + email: adminInput.email, + actorId + }); + + failed.push({ + email: adminInput.email, + error: error.message || 'Failed to create admin' + }); + } + } + + const response: BulkCreateAdminResponse = { + created, + failed + }; + + // Return 207 Multi-Status if there were any failures, 201 if all succeeded + const statusCode = failed.length > 0 ? 207 : 201; + + return reply.code(statusCode).send(response); + } catch (error: any) { + logger.error('Error in bulk create admins', { + error: error.message, + actorId: request.userContext?.userId + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to process bulk admin creation' + }); + } + } + + /** + * PATCH /api/admin/admins/bulk-revoke - Revoke multiple admin users + */ + async bulkRevokeAdmins( + request: FastifyRequest<{ Body: BulkRevokeAdminInput }>, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing' + }); + } + + // Validate request body + const validation = bulkRevokeAdminSchema.safeParse(request.body); + if (!validation.success) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: validation.error.errors + }); + } + + const { auth0Subs } = validation.data; + + const revoked: AdminUser[] = []; + const failed: Array<{ auth0Sub: string; error: string }> = []; + + // Process each revocation sequentially to maintain data consistency + for (const auth0Sub of auth0Subs) { + try { + // Check if admin exists + const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); + if (!targetAdmin) { + failed.push({ + auth0Sub, + error: 'Admin user not found' + }); + continue; + } + + // Attempt to revoke the admin + const admin = await this.adminService.revokeAdmin(auth0Sub, actorId); + revoked.push(admin); + } catch (error: any) { + logger.error('Error revoking admin in bulk operation', { + error: error.message, + auth0Sub, + actorId + }); + + // Special handling for "last admin" constraint + failed.push({ + auth0Sub, + error: error.message || 'Failed to revoke admin' + }); + } + } + + const response: BulkRevokeAdminResponse = { + revoked, + failed + }; + + // Return 207 Multi-Status if there were any failures, 200 if all succeeded + const statusCode = failed.length > 0 ? 207 : 200; + + return reply.code(statusCode).send(response); + } catch (error: any) { + logger.error('Error in bulk revoke admins', { + error: error.message, + actorId: request.userContext?.userId + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to process bulk admin revocation' + }); + } + } + + /** + * PATCH /api/admin/admins/bulk-reinstate - Reinstate multiple revoked admin users + */ + async bulkReinstateAdmins( + request: FastifyRequest<{ Body: BulkReinstateAdminInput }>, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing' + }); + } + + // Validate request body + const validation = bulkReinstateAdminSchema.safeParse(request.body); + if (!validation.success) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: validation.error.errors + }); + } + + const { auth0Subs } = validation.data; + + const reinstated: AdminUser[] = []; + const failed: Array<{ auth0Sub: string; error: string }> = []; + + // Process each reinstatement sequentially to maintain data consistency + for (const auth0Sub of auth0Subs) { + try { + // Check if admin exists + const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); + if (!targetAdmin) { + failed.push({ + auth0Sub, + error: 'Admin user not found' + }); + continue; + } + + // Attempt to reinstate the admin + const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId); + reinstated.push(admin); + } catch (error: any) { + logger.error('Error reinstating admin in bulk operation', { + error: error.message, + auth0Sub, + actorId + }); + + failed.push({ + auth0Sub, + error: error.message || 'Failed to reinstate admin' + }); + } + } + + const response: BulkReinstateAdminResponse = { + reinstated, + failed + }; + + // Return 207 Multi-Status if there were any failures, 200 if all succeeded + const statusCode = failed.length > 0 ? 207 : 200; + + return reply.code(statusCode).send(response); + } catch (error: any) { + logger.error('Error in bulk reinstate admins', { + error: error.message, + actorId: request.userContext?.userId + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to process bulk admin reinstatement' + }); + } + } + private resolveUserEmail(request: FastifyRequest): string | undefined { console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2)); console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2)); diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index 5cb2932..4b8e5a7 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -8,7 +8,12 @@ import { AdminController } from './admin.controller'; import { CreateAdminInput, AdminAuth0SubInput, - AuditLogsQueryInput + AuditLogsQueryInput, + BulkCreateAdminInput, + BulkRevokeAdminInput, + BulkReinstateAdminInput, + BulkDeleteCatalogInput, + CatalogEntity } from './admin.validation'; import { AdminRepository } from '../data/admin.repository'; import { StationOversightService } from '../domain/station-oversight.service'; @@ -69,6 +74,24 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: adminController.getAuditLogs.bind(adminController) }); + // POST /api/admin/admins/bulk - Create multiple admins + fastify.post<{ Body: BulkCreateAdminInput }>('/admin/admins/bulk', { + preHandler: [fastify.requireAdmin], + handler: adminController.bulkCreateAdmins.bind(adminController) + }); + + // PATCH /api/admin/admins/bulk-revoke - Revoke multiple admins + fastify.patch<{ Body: BulkRevokeAdminInput }>('/admin/admins/bulk-revoke', { + preHandler: [fastify.requireAdmin], + handler: adminController.bulkRevokeAdmins.bind(adminController) + }); + + // PATCH /api/admin/admins/bulk-reinstate - Reinstate multiple admins + fastify.patch<{ Body: BulkReinstateAdminInput }>('/admin/admins/bulk-reinstate', { + preHandler: [fastify.requireAdmin], + handler: adminController.bulkReinstateAdmins.bind(adminController) + }); + // Phase 3: Catalog CRUD endpoints // Makes endpoints @@ -182,6 +205,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: catalogController.getChangeLogs.bind(catalogController) }); + // Bulk delete endpoint + fastify.delete<{ Params: { entity: CatalogEntity }; Body: BulkDeleteCatalogInput }>('/admin/catalog/:entity/bulk-delete', { + preHandler: [fastify.requireAdmin], + handler: catalogController.bulkDeleteCatalogEntity.bind(catalogController) + }); + // Phase 4: Station oversight endpoints // GET /api/admin/stations - List all stations globally diff --git a/backend/src/features/admin/api/admin.validation.ts b/backend/src/features/admin/api/admin.validation.ts index 85137f3..2e2d9ba 100644 --- a/backend/src/features/admin/api/admin.validation.ts +++ b/backend/src/features/admin/api/admin.validation.ts @@ -19,6 +19,40 @@ export const auditLogsQuerySchema = z.object({ offset: z.coerce.number().min(0).default(0), }); +export const bulkCreateAdminSchema = z.object({ + admins: z.array( + z.object({ + email: z.string().email('Invalid email format'), + role: z.enum(['admin', 'super_admin']).optional().default('admin'), + }) + ).min(1, 'At least one admin must be provided').max(100, 'Maximum 100 admins per batch'), +}); + +export const bulkRevokeAdminSchema = z.object({ + auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) + .min(1, 'At least one auth0Sub must be provided') + .max(100, 'Maximum 100 admins per batch'), +}); + +export const bulkReinstateAdminSchema = z.object({ + auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) + .min(1, 'At least one auth0Sub must be provided') + .max(100, 'Maximum 100 admins per batch'), +}); + +export const catalogEntitySchema = z.enum(['makes', 'models', 'years', 'trims', 'engines']); + +export const bulkDeleteCatalogSchema = z.object({ + ids: z.array(z.number().int().positive('ID must be a positive integer')) + .min(1, 'At least one ID must be provided') + .max(100, 'Maximum 100 items per batch'), +}); + export type CreateAdminInput = z.infer; export type AdminAuth0SubInput = z.infer; export type AuditLogsQueryInput = z.infer; +export type BulkCreateAdminInput = z.infer; +export type BulkRevokeAdminInput = z.infer; +export type BulkReinstateAdminInput = z.infer; +export type CatalogEntity = z.infer; +export type BulkDeleteCatalogInput = z.infer; diff --git a/backend/src/features/admin/api/catalog.controller.ts b/backend/src/features/admin/api/catalog.controller.ts index 576a4cf..0bc7243 100644 --- a/backend/src/features/admin/api/catalog.controller.ts +++ b/backend/src/features/admin/api/catalog.controller.ts @@ -536,4 +536,103 @@ export class CatalogController { reply.code(500).send({ error: 'Failed to retrieve change logs' }); } } + + // BULK DELETE ENDPOINT + + async bulkDeleteCatalogEntity( + request: FastifyRequest<{ Params: { entity: string }; Body: { ids: number[] } }>, + reply: FastifyReply + ): Promise { + try { + const { entity } = request.params; + const { ids } = request.body; + const actorId = request.userContext?.userId || 'unknown'; + + // Validate entity type + const validEntities = ['makes', 'models', 'years', 'trims', 'engines']; + if (!validEntities.includes(entity)) { + reply.code(400).send({ + error: 'Invalid entity type', + message: `Entity must be one of: ${validEntities.join(', ')}` + }); + return; + } + + // Validate IDs are provided + if (!ids || !Array.isArray(ids) || ids.length === 0) { + reply.code(400).send({ + error: 'Invalid request', + message: 'At least one ID must be provided' + }); + return; + } + + // Validate all IDs are valid integers + const invalidIds = ids.filter(id => !Number.isInteger(id) || id <= 0); + if (invalidIds.length > 0) { + reply.code(400).send({ + error: 'Invalid IDs', + message: 'All IDs must be positive integers' + }); + return; + } + + const deleted: number[] = []; + const failed: Array<{ id: number; error: string }> = []; + + // Map entity to delete method + const deleteMethodMap: Record Promise> = { + makes: (id, actor) => this.catalogService.deleteMake(id, actor), + models: (id, actor) => this.catalogService.deleteModel(id, actor), + years: (id, actor) => this.catalogService.deleteYear(id, actor), + trims: (id, actor) => this.catalogService.deleteTrim(id, actor), + engines: (id, actor) => this.catalogService.deleteEngine(id, actor) + }; + + const deleteMethod = deleteMethodMap[entity]; + + // Process each deletion sequentially to maintain data consistency + for (const id of ids) { + try { + await deleteMethod(id, actorId); + deleted.push(id); + } catch (error: any) { + logger.error(`Error deleting ${entity} in bulk operation`, { + error: error.message, + entity, + id, + actorId + }); + + failed.push({ + id, + error: error.message || `Failed to delete ${entity}` + }); + } + } + + const response = { + deleted, + failed + }; + + // Return 207 Multi-Status if there were any failures, 204 if all succeeded + if (failed.length > 0) { + reply.code(207).send(response); + } else { + reply.code(204).send(); + } + } catch (error: any) { + logger.error('Error in bulk delete catalog entity', { + error: error.message, + entity: request.params.entity, + actorId: request.userContext?.userId + }); + + reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to process bulk deletion' + }); + } + } } diff --git a/backend/src/features/admin/domain/admin.types.ts b/backend/src/features/admin/domain/admin.types.ts index 845ccb4..ac960e1 100644 --- a/backend/src/features/admin/domain/admin.types.ts +++ b/backend/src/features/admin/domain/admin.types.ts @@ -53,3 +53,51 @@ export interface AdminAuditResponse { total: number; logs: AdminAuditLog[]; } + +// Batch operation types +export interface BulkCreateAdminRequest { + admins: Array<{ + email: string; + role?: 'admin' | 'super_admin'; + }>; +} + +export interface BulkCreateAdminResponse { + created: AdminUser[]; + failed: Array<{ + email: string; + error: string; + }>; +} + +export interface BulkRevokeAdminRequest { + auth0Subs: string[]; +} + +export interface BulkRevokeAdminResponse { + revoked: AdminUser[]; + failed: Array<{ + auth0Sub: string; + error: string; + }>; +} + +export interface BulkReinstateAdminRequest { + auth0Subs: string[]; +} + +export interface BulkReinstateAdminResponse { + reinstated: AdminUser[]; + failed: Array<{ + auth0Sub: string; + error: string; + }>; +} + +export interface BulkDeleteCatalogResponse { + deleted: number[]; + failed: Array<{ + id: number; + error: string; + }>; +} diff --git a/docs/ADMIN-DEPLOYMENT-CHECKLIST.md b/docs/ADMIN-DEPLOYMENT-CHECKLIST.md deleted file mode 100644 index 23e4c4b..0000000 --- a/docs/ADMIN-DEPLOYMENT-CHECKLIST.md +++ /dev/null @@ -1,343 +0,0 @@ -# Admin Feature Deployment Checklist - -Production deployment checklist for the Admin feature (Phases 1-5 complete). - -## Pre-Deployment Verification (Phase 6) - -### Code Quality Gates - -- [ ] **TypeScript compilation**: `npm run build` - Zero errors -- [ ] **Linting**: `npm run lint` - Zero warnings -- [ ] **Backend tests**: `npm test -- features/admin` - All passing -- [ ] **Frontend tests**: `npm test` - All passing -- [ ] **Container builds**: `make rebuild` - Success -- [ ] **Backend startup**: `make start` - Server running on port 3001 -- [ ] **Health checks**: `curl https://motovaultpro.com/api/health` - 200 OK -- [ ] **Frontend build**: Vite build completes in <20 seconds -- [ ] **No deprecated code**: All old code related to admin removed -- [ ] **Documentation complete**: ADMIN.md, feature READMEs updated - -### Security Verification - -- [ ] **Parameterized queries**: Grep confirms no SQL concatenation in admin feature -- [ ] **Input validation**: All endpoints validate with Zod schemas -- [ ] **HTTPS only**: Verify Traefik configured for HTTPS -- [ ] **Auth0 integration**: Dev/prod Auth0 domains match configuration -- [ ] **JWT validation**: Token verification working in auth plugin -- [ ] **Admin guard**: `fastify.requireAdmin` blocking non-admins with 403 -- [ ] **Audit logging**: All admin actions logged to database -- [ ] **Last admin protection**: Confirmed system cannot revoke last admin - -### Database Verification - -- [ ] **Migrations exist**: Both migration files present - - `backend/src/features/admin/migrations/001_create_admin_users.sql` - - `backend/src/features/admin/migrations/002_create_platform_change_log.sql` -- [ ] **Tables created**: Run migrations verify - ```bash - docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \ - "\dt admin_users admin_audit_logs platform_change_log" - ``` -- [ ] **Initial admin seeded**: Verify bootstrap admin exists - ```bash - docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \ - "SELECT email, role, revoked_at FROM admin_users WHERE auth0_sub = 'system|bootstrap';" - ``` -- [ ] **Indexes created**: Verify all indexes exist - ```bash - docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \ - "SELECT tablename, indexname FROM pg_indexes WHERE tablename IN ('admin_users', 'admin_audit_logs', 'platform_change_log');" - ``` -- [ ] **Foreign keys configured**: Cascade rules work correctly -- [ ] **Backup tested**: Database backup includes new tables - -### API Verification - -#### Phase 2 Endpoints (Admin Management) - -- [ ] **GET /api/admin/admins** - Returns all admins - ```bash - curl -H "Authorization: Bearer $JWT" https://motovaultpro.com/api/admin/admins - ``` -- [ ] **POST /api/admin/admins** - Creates admin (with valid email) -- [ ] **PATCH /api/admin/admins/:auth0Sub/revoke** - Revokes admin -- [ ] **PATCH /api/admin/admins/:auth0Sub/reinstate** - Reinstates admin -- [ ] **GET /api/admin/audit-logs** - Returns audit trail -- [ ] **403 Forbidden** - Non-admin user blocked from all endpoints - -#### Phase 3 Endpoints (Catalog CRUD) - -- [ ] **GET /api/admin/catalog/makes** - List makes -- [ ] **POST /api/admin/catalog/makes** - Create make -- [ ] **PUT /api/admin/catalog/makes/:makeId** - Update make -- [ ] **DELETE /api/admin/catalog/makes/:makeId** - Delete make -- [ ] **GET /api/admin/catalog/change-logs** - View change history -- [ ] **Cache invalidation**: Redis keys flushed after mutations -- [ ] **Transaction support**: Failed mutations rollback cleanly - -#### Phase 4 Endpoints (Station Oversight) - -- [ ] **GET /api/admin/stations** - List all stations -- [ ] **POST /api/admin/stations** - Create station -- [ ] **PUT /api/admin/stations/:stationId** - Update station -- [ ] **DELETE /api/admin/stations/:stationId** - Soft delete (default) -- [ ] **DELETE /api/admin/stations/:stationId?force=true** - Hard delete -- [ ] **GET /api/admin/users/:userId/stations** - User's saved stations -- [ ] **DELETE /api/admin/users/:userId/stations/:stationId** - Remove user station -- [ ] **Cache invalidation**: `mvp:stations:*` keys flushed - -### Frontend Verification (Mobile + Desktop) - -#### Desktop Verification - -- [ ] **Admin console visible** - SettingsPage shows "Admin Console" card when admin -- [ ] **Non-admin message** - Non-admin users see "Not authorized" message -- [ ] **Navigation links work** - Admin/Users, Admin/Catalog, Admin/Stations accessible -- [ ] **Admin pages load** - Route guards working, 403 page for non-admins -- [ ] **useAdminAccess hook** - Loading state shows spinner while checking admin status - -#### Mobile Verification (375px viewport) - -- [ ] **Admin section visible** - MobileSettingsScreen shows admin section when admin -- [ ] **Admin section hidden** - Completely hidden for non-admin users -- [ ] **Touch targets** - All buttons are ≥44px height -- [ ] **Mobile pages load** - Routes accessible on mobile -- [ ] **No layout issues** - Text readable, buttons tappable on 375px screen -- [ ] **Loading states** - Proper spinner on admin data loads - -#### Responsive Design - -- [ ] **Desktop 1920px** - All pages display correctly -- [ ] **Mobile 375px** - All pages responsive, no horizontal scroll -- [ ] **Tablet 768px** - Intermediate sizing works -- [ ] **No console errors** - Check browser DevTools -- [ ] **Performance acceptable** - Page load <3s on mobile - -### Integration Testing - -- [ ] **End-to-end workflow**: - 1. Login as admin - 2. Navigate to admin console - 3. Create new admin user - 4. Verify audit log entry - 5. Revoke new admin - 6. Verify last admin protection prevents revocation of only remaining admin - 7. Create catalog item - 8. Verify cache invalidation - 9. Create station - 10. Verify soft/hard delete behavior - -- [ ] **Error handling**: - - [ ] 400 Bad Request - Invalid input (test with malformed JSON) - - [ ] 403 Forbidden - Non-admin access attempt - - [ ] 404 Not Found - Nonexistent resource - - [ ] 409 Conflict - Referential integrity violation - - [ ] 500 Internal Server Error - Database connection failure - -- [ ] **Audit trail verification**: - - [ ] All admin management actions logged - - [ ] All catalog mutations recorded with old/new values - - [ ] All station operations tracked - - [ ] Actor admin ID correctly stored - -### Performance Verification - -- [ ] **Query performance**: Admin list returns <100ms (verify in logs) -- [ ] **Large dataset handling**: Test with 1000+ audit logs -- [ ] **Cache efficiency**: Repeated queries use cache -- [ ] **No N+1 queries**: Verify in query logs -- [ ] **Pagination works**: Limit/offset parameters functioning - -### Monitoring & Logging - -- [ ] **Admin logs visible**: `make logs | grep -i admin` shows entries -- [ ] **Audit trail stored**: `SELECT COUNT(*) FROM admin_audit_logs;` > 0 -- [ ] **Error logging**: Failed operations logged with context -- [ ] **Performance metrics**: Slow queries logged - -### Documentation - -- [ ] **ADMIN.md complete**: All endpoints documented -- [ ] **API examples provided**: Sample requests/responses included -- [ ] **Security notes documented**: Input validation, parameterized queries explained -- [ ] **Deployment section**: Clear instructions for operators -- [ ] **Troubleshooting guide**: Common issues and solutions -- [ ] **Backend feature README**: Phase descriptions, extending guide -- [ ] **docs/README.md updated**: Admin references added - -## Deployment Steps - -### 1. Pre-Deployment - -```bash -# Verify all tests pass -npm test -- features/admin -docker compose exec mvp-frontend npm test - -# Verify builds succeed -make rebuild - -# Backup database -./scripts/backup-database.sh - -# Verify rollback plan documented -cat docs/ADMIN.md | grep -A 20 "## Rollback" -``` - -### 2. Database Migration - -```bash -# Run migrations (automatic on container startup, or manual) -docker compose exec mvp-backend npm run migrate - -# Verify tables and seed data -docker compose exec mvp-backend psql -U postgres -d motovaultpro -c \ - "SELECT COUNT(*) FROM admin_users; SELECT COUNT(*) FROM admin_audit_logs;" -``` - -### 3. Container Deployment - -```bash -# Stop current containers -docker compose down - -# Pull latest code -git pull origin main - -# Rebuild containers with latest code -make rebuild - -# Start services -make start - -# Verify health -make logs | grep -i "Backend is healthy" -curl https://motovaultpro.com/api/health -``` - -### 4. Post-Deployment Verification - -```bash -# Verify health endpoints -curl https://motovaultpro.com/api/health | jq .features - -# Test admin endpoint (with valid JWT) -curl -H "Authorization: Bearer $JWT" \ - https://motovaultpro.com/api/admin/admins | jq .total - -# Verify frontend loads -curl -s https://motovaultpro.com | grep -q "motovaultpro" && echo "Frontend OK" - -# Check logs for errors -make logs | grep -i error | head -20 -``` - -### 5. Smoke Tests (Manual) - -1. **Desktop**: - - Visit https://motovaultpro.com - - Login with admin account - - Navigate to Settings - - Verify "Admin Console" card visible - - Click "User Management" - - Verify admin list loads - -2. **Mobile**: - - Open https://motovaultpro.com on mobile device or dev tools (375px) - - Login with admin account - - Navigate to Settings - - Verify admin section visible - - Tap "Users" - - Verify admin list loads - -3. **Non-Admin**: - - Login with non-admin account - - Navigate to `/garage/settings/admin/users` - - Verify 403 Forbidden page displayed - - Check that admin console NOT visible on settings page - -## Rollback Procedure - -If critical issues found after deployment: - -```bash -# 1. Revert code to previous version -git revert HEAD -docker compose down -make rebuild -make start - -# 2. If database schema issue, restore from backup -./scripts/restore-database.sh backup-timestamp.sql - -# 3. Verify health -curl https://motovaultpro.com/api/health - -# 4. Test rollback endpoints -curl -H "Authorization: Bearer $JWT" \ - https://motovaultpro.com/api/vehicles/ - -# 5. Monitor logs for 30 minutes -make logs | tail -f -``` - -## Supported Browsers - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## Known Limitations - -- Admin feature requires JavaScript enabled -- Mobile UI optimized for portrait orientation -- Catalog changes may take 5 minutes to propagate in cache - -## Sign-Off - -- [ ] **Tech Lead**: All quality gates passed ______________________ Date: _______ -- [ ] **QA**: End-to-end testing complete ______________________ Date: _______ -- [ ] **DevOps**: Deployment procedure verified ______________________ Date: _______ -- [ ] **Product**: Feature acceptance confirmed ______________________ Date: _______ - -## Post-Deployment Monitoring - -Monitor for 24 hours: -- [ ] Health check endpoint responding -- [ ] No 500 errors in logs -- [ ] Admin operations completing <500ms -- [ ] No database connection errors -- [ ] Memory usage stable -- [ ] Disk space adequate -- [ ] All feature endpoints responding - -## Release Notes - -```markdown -## Admin Feature (v1.0) - -### New Features - -- Admin role and access control (Phase 1) -- Admin user management with audit trail (Phase 2) -- Vehicle catalog CRUD operations (Phase 3) -- Gas station oversight and management (Phase 4) -- Admin UI for desktop and mobile (Phase 5) - -### Breaking Changes - -None - -### Migration Required - -Yes - Run `npm run migrate` automatically on container startup - -### Rollback Available - -Yes - See ADMIN-DEPLOYMENT-CHECKLIST.md - -### Documentation - -See `docs/ADMIN.md` for complete reference -``` diff --git a/docs/ADMIN-IMPLEMENTATION-SUMMARY.md b/docs/ADMIN-IMPLEMENTATION-SUMMARY.md deleted file mode 100644 index b981fd3..0000000 --- a/docs/ADMIN-IMPLEMENTATION-SUMMARY.md +++ /dev/null @@ -1,440 +0,0 @@ -# Admin Feature Implementation Summary - -Complete implementation of the admin feature for MotoVaultPro across all 6 phases. - -## Executive Summary - -Successfully implemented a complete admin role management system with cross-tenant CRUD authority for platform catalog and station management. All phases completed in parallel, with comprehensive testing, documentation, and deployment procedures. - -**Status:** PRODUCTION READY - -## Implementation Overview - -### Phase 1: Access Control Foundations ✅ COMPLETE - -**Deliverables:** -- `backend/src/features/admin/` - Feature capsule directory structure -- `001_create_admin_users.sql` - Database schema for admin users and audit logs -- `admin.types.ts` - TypeScript type definitions -- `admin.repository.ts` - Data access layer with parameterized queries -- `admin-guard.plugin.ts` - Fastify authorization plugin -- Enhanced auth plugin with `request.userContext` - -**Key Features:** -- Admin user tracking with `auth0_sub` primary key -- Admin audit logs for all actions -- Last admin protection (cannot revoke last active admin) -- Soft-delete via `revoked_at` timestamp -- All queries parameterized (no SQL injection risk) - -**Status:** Verified in containers - database tables created and seeded - -### Phase 2: Admin Management APIs ✅ COMPLETE - -**Endpoints Implemented:** 5 - -1. `GET /api/admin/admins` - List all admin users (active and revoked) -2. `POST /api/admin/admins` - Create new admin (with validation) -3. `PATCH /api/admin/admins/:auth0Sub/revoke` - Revoke admin access (prevents last admin revocation) -4. `PATCH /api/admin/admins/:auth0Sub/reinstate` - Restore revoked admin -5. `GET /api/admin/audit-logs` - Retrieve audit trail (paginated) - -**Implementation Files:** -- `admin.controller.ts` - HTTP request handlers -- `admin.validation.ts` - Zod input validation schemas -- Integration tests - Full API endpoint coverage - -**Security:** -- All endpoints require `fastify.requireAdmin` guard -- Input validation on all endpoints (email format, role enum, required fields) -- Audit logging on all actions -- Last admin protection prevents system lockout - -### Phase 3: Platform Catalog CRUD ✅ COMPLETE - -**Endpoints Implemented:** 21 - -- **Makes**: GET, POST, PUT, DELETE (4 endpoints) -- **Models**: GET (by make), POST, PUT, DELETE (4 endpoints) -- **Years**: GET (by model), POST, PUT, DELETE (4 endpoints) -- **Trims**: GET (by year), POST, PUT, DELETE (4 endpoints) -- **Engines**: GET (by trim), POST, PUT, DELETE (4 endpoints) -- **Change Logs**: GET with pagination (1 endpoint) - -**Implementation Files:** -- `vehicle-catalog.service.ts` - Service layer with transaction support -- `catalog.controller.ts` - HTTP handlers for all catalog operations -- `002_create_platform_change_log.sql` - Audit log table for catalog changes - -**Key Features:** -- Transaction support - All mutations wrapped in BEGIN/COMMIT/ROLLBACK -- Cache invalidation - `platform:*` Redis keys flushed on mutations -- Referential integrity - Prevents orphan deletions -- Change history - All mutations logged with old/new values -- Complete audit trail - Who made what changes and when - -### Phase 4: Station Oversight ✅ COMPLETE - -**Endpoints Implemented:** 6 - -1. `GET /api/admin/stations` - List all stations (with pagination and search) -2. `POST /api/admin/stations` - Create new station -3. `PUT /api/admin/stations/:stationId` - Update station -4. `DELETE /api/admin/stations/:stationId` - Delete station (soft or hard) -5. `GET /api/admin/users/:userId/stations` - List user's saved stations -6. `DELETE /api/admin/users/:userId/stations/:stationId` - Remove user station (soft or hard) - -**Implementation Files:** -- `station-oversight.service.ts` - Service layer for station operations -- `stations.controller.ts` - HTTP handlers - -**Key Features:** -- Soft delete by default (sets `deleted_at` timestamp) -- Hard delete with `?force=true` query parameter -- Cache invalidation - `mvp:stations:*` and `mvp:stations:saved:{userId}` keys -- Pagination support - `limit` and `offset` query parameters -- Search support - `?search=query` filters stations -- Audit logging - All mutations tracked - -### Phase 5: UI Integration (Frontend) ✅ COMPLETE - -**Mobile + Desktop Implementation - BOTH REQUIRED** - -**Components Created:** - -**Desktop Pages:** -- `AdminUsersPage.tsx` - Manage admin users -- `AdminCatalogPage.tsx` - Manage vehicle catalog -- `AdminStationsPage.tsx` - Manage gas stations - -**Mobile Screens (separate implementations):** -- `AdminUsersMobileScreen.tsx` - Mobile user management -- `AdminCatalogMobileScreen.tsx` - Mobile catalog management -- `AdminStationsMobileScreen.tsx` - Mobile station management - -**Core Infrastructure:** -- `useAdminAccess.ts` hook - Verify admin status (loading, error, not-admin states) -- `useAdmins.ts` - React Query hooks for admin CRUD -- `useCatalog.ts` - React Query hooks for catalog operations -- `useStationOverview.ts` - React Query hooks for station management -- `admin.api.ts` - API client functions -- `admin.types.ts` - TypeScript types mirroring backend - -**Integration:** -- Settings page updated with "Admin Console" card (desktop) -- MobileSettingsScreen updated with admin section (mobile) -- Routes added to App.tsx with admin guards -- Route guards verify `useAdminAccess` before allowing access - -**Responsive Design:** -- Desktop: 1920px viewport - Full MUI components -- Mobile: 375px viewport - Touch-optimized GlassCard pattern -- Separate implementations (not responsive components) -- Touch targets ≥44px on mobile -- No horizontal scroll on mobile - -### Phase 6: Quality Gates & Documentation ✅ COMPLETE - -**Documentation Created:** - -1. **docs/ADMIN.md** - Comprehensive feature documentation - - Architecture overview - - Database schema reference - - Complete API reference with examples - - Authorization rules and security considerations - - Deployment procedures - - Troubleshooting guide - - Performance monitoring - -2. **docs/ADMIN-DEPLOYMENT-CHECKLIST.md** - Production deployment guide - - Pre-deployment verification (80+ checkpoints) - - Code quality gates verification - - Security verification - - Database verification - - API endpoint testing procedures - - Frontend verification (mobile + desktop) - - Integration testing procedures - - Performance testing - - Post-deployment monitoring - - Rollback procedures - - Sign-off sections - -3. **docs/ADMIN-IMPLEMENTATION-SUMMARY.md** - This document - - Overview of all 6 phases - - Files created/modified - - Verification results - - Risk assessment - - Next steps - -**Documentation Updates:** -- Updated `docs/README.md` with admin references -- Updated `backend/src/features/admin/README.md` with completion status -- Updated health check endpoint to include admin feature - -**Code Quality:** -- TypeScript compilation: ✅ Successful (containers build without errors) -- Linting: ✅ Verified (no style violations) -- Container builds: ✅ Successful (multi-stage Docker build passes) -- Backend startup: ✅ Running on port 3001 -- Health checks: ✅ Returning 200 with features list including 'admin' -- Redis connectivity: ✅ Connected and working -- Database migrations: ✅ All 3 admin tables created -- Initial seed: ✅ Bootstrap admin seeded (admin@motovaultpro.com) - -## File Summary - -### Backend Files Created (30+ files) - -**Core:** -- `backend/src/features/admin/api/admin.controller.ts` -- `backend/src/features/admin/api/admin.validation.ts` -- `backend/src/features/admin/api/admin.routes.ts` -- `backend/src/features/admin/api/catalog.controller.ts` -- `backend/src/features/admin/api/stations.controller.ts` - -**Domain:** -- `backend/src/features/admin/domain/admin.types.ts` -- `backend/src/features/admin/domain/admin.service.ts` -- `backend/src/features/admin/domain/vehicle-catalog.service.ts` -- `backend/src/features/admin/domain/station-oversight.service.ts` - -**Data:** -- `backend/src/features/admin/data/admin.repository.ts` - -**Migrations:** -- `backend/src/features/admin/migrations/001_create_admin_users.sql` -- `backend/src/features/admin/migrations/002_create_platform_change_log.sql` - -**Tests:** -- `backend/src/features/admin/tests/unit/admin.guard.test.ts` -- `backend/src/features/admin/tests/unit/admin.service.test.ts` -- `backend/src/features/admin/tests/integration/admin.integration.test.ts` -- `backend/src/features/admin/tests/integration/catalog.integration.test.ts` -- `backend/src/features/admin/tests/integration/stations.integration.test.ts` - -**Core Plugins:** -- `backend/src/core/plugins/admin-guard.plugin.ts` -- Enhanced: `backend/src/core/plugins/auth.plugin.ts` - -**Configuration:** -- Updated: `backend/src/app.ts` (admin plugin registration, route registration, health checks) -- Updated: `backend/src/_system/migrations/run-all.ts` (added admin to migration order) - -### Frontend Files Created (15+ files) - -**Types & API:** -- `frontend/src/features/admin/types/admin.types.ts` -- `frontend/src/features/admin/api/admin.api.ts` - -**Hooks:** -- `frontend/src/core/auth/useAdminAccess.ts` -- `frontend/src/features/admin/hooks/useAdmins.ts` -- `frontend/src/features/admin/hooks/useCatalog.ts` -- `frontend/src/features/admin/hooks/useStationOverview.ts` - -**Pages (Desktop):** -- `frontend/src/pages/admin/AdminUsersPage.tsx` -- `frontend/src/pages/admin/AdminCatalogPage.tsx` -- `frontend/src/pages/admin/AdminStationsPage.tsx` - -**Screens (Mobile):** -- `frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx` -- `frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx` -- `frontend/src/features/admin/mobile/AdminStationsMobileScreen.tsx` - -**Tests:** -- `frontend/src/features/admin/__tests__/useAdminAccess.test.ts` -- `frontend/src/features/admin/__tests__/useAdmins.test.ts` -- `frontend/src/features/admin/__tests__/AdminUsersPage.test.tsx` - -**UI Integration:** -- Updated: `frontend/src/pages/SettingsPage.tsx` (admin console card) -- Updated: `frontend/src/features/settings/mobile/MobileSettingsScreen.tsx` (admin section) -- Updated: `frontend/src/App.tsx` (admin routes and guards) - -### Documentation Files Created - -- `docs/ADMIN.md` - Comprehensive reference (400+ lines) -- `docs/ADMIN-DEPLOYMENT-CHECKLIST.md` - Deployment guide (500+ lines) -- `docs/ADMIN-IMPLEMENTATION-SUMMARY.md` - This summary - -### Documentation Files Updated - -- `docs/README.md` - Added admin references -- `backend/src/features/admin/README.md` - Completed phase descriptions - -## Database Verification - -### Tables Created ✅ - -``` -admin_users (admin_audit_logs, platform_change_log also created) -``` - -**admin_users:** -``` - email | role | revoked_at -------------------------+-------+------------ - admin@motovaultpro.com | admin | (null) -(1 row) -``` - -**Indexes verified:** -- `idx_admin_users_email` - For lookups -- `idx_admin_users_created_at` - For audit trails -- `idx_admin_users_revoked_at` - For active admin queries -- All platform_change_log indexes created - -**Triggers verified:** -- `update_admin_users_updated_at` - Auto-update timestamp - -## Backend Verification - -### Health Endpoint ✅ - -``` -GET /api/health → 200 OK -Features: [admin, vehicles, documents, fuel-logs, stations, maintenance, platform] -Status: healthy -Redis: connected -``` - -### Migrations ✅ - -``` -✅ features/admin/001_create_admin_users.sql - Completed -✅ features/admin/002_create_platform_change_log.sql - Skipped (already executed) -✅ All migrations completed successfully -``` - -### Container Status ✅ - -- Backend running on port 3001 -- Configuration loaded successfully -- Redis connected -- Database migrations orchestrated correctly - -## Remaining Tasks & Risks - -### Low Priority (Future Phases) - -1. **Full CRUD UI implementation** - Admin pages currently have route stubs, full forms needed -2. **Role-based permissions** - Extend from binary admin to granular roles -3. **2FA for admins** - Enhanced security requirement -4. **Bulk import/export** - Catalog data management improvements -5. **Advanced analytics** - Admin activity dashboards - -### Known Limitations - -- Admin feature requires JavaScript enabled -- Mobile UI optimized for portrait orientation (landscape partially supported) -- Catalog changes may take 5 minutes to propagate in cache (configurable) - -### Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|-----------| -| Last admin revoked (system lockout) | Low | Critical | Business logic prevents this | -| SQL injection | Very Low | Critical | All queries parameterized | -| Unauthorized admin access | Low | High | Guard plugin on all routes | -| Cache consistency | Medium | Medium | Redis invalidation on mutations | -| Migration order issue | Low | High | Explicit MIGRATION_ORDER array | - -## Deployment Readiness Checklist - -- ✅ All 5 phases implemented -- ✅ Code compiles without errors -- ✅ Containers build successfully -- ✅ Migrations run correctly -- ✅ Database schema verified -- ✅ Backend health checks passing -- ✅ Admin guard working (verified in logs) -- ✅ Comprehensive documentation created -- ✅ Deployment checklist prepared -- ✅ Rollback procedures documented -- ⚠️ Integration tests created (require test runner setup) -- ⚠️ E2E tests created (manual verification needed) - -## Quick Start for Developers - -### Running the Admin Feature - -```bash -# Build and start containers -make rebuild -make start - -# Verify health -curl https://motovaultpro.com/api/health | jq .features - -# Test admin endpoint (requires valid JWT) -curl -H "Authorization: Bearer $JWT" \ - https://motovaultpro.com/api/admin/admins - -# Check logs -make logs | grep admin -``` - -### Using the Admin UI - -**Desktop:** -1. Navigate to https://motovaultpro.com -2. Login with admin account -3. Go to Settings -4. Click "Admin Console" card - -**Mobile:** -1. Navigate to https://motovaultpro.com on mobile (375px) -2. Login with admin account -3. Go to Settings -4. Tap "Admin" section -5. Select operation (Users, Catalog, Stations) - -### Default Admin Credentials - -- **Email:** admin@motovaultpro.com -- **Auth0 ID:** system|bootstrap -- **Role:** admin -- **Status:** Active (not revoked) - -## Performance Baselines - -- Health check: <5ms -- List admins: <100ms -- Create admin: <200ms -- List stations: <500ms (1000+ records) -- Catalog CRUD: <300ms per operation - -## References - -- **Architecture:** `docs/PLATFORM-SERVICES.md` -- **API Reference:** `docs/ADMIN.md` -- **Deployment Guide:** `docs/ADMIN-DEPLOYMENT-CHECKLIST.md` -- **Backend Feature:** `backend/src/features/admin/README.md` -- **Testing Guide:** `docs/TESTING.md` -- **Security:** `docs/SECURITY.md` - -## Sign-Off - -| Role | Approval | Date | Notes | -|------|----------|------|-------| -| Implementation | ✅ Complete | 2025-11-05 | All 6 phases done | -| Code Quality | ✅ Verified | 2025-11-05 | Builds, migrations run, health OK | -| Documentation | ✅ Complete | 2025-11-05 | ADMIN.md, deployment checklist | -| Security | ✅ Reviewed | 2025-11-05 | Parameterized queries, guards | -| Testing | ✅ Created | 2025-11-05 | Unit, integration, E2E test files | - -## Next Steps - -1. **Immediate:** Run full deployment checklist before production deployment -2. **Testing:** Execute integration and E2E tests in test environment -3. **Validation:** Smoke test on staging environment (desktop + mobile) -4. **Rollout:** Deploy to production following ADMIN-DEPLOYMENT-CHECKLIST.md -5. **Monitoring:** Monitor for 24 hours post-deployment -6. **Future:** Implement UI refinements and additional features (role-based permissions, 2FA) - ---- - -**Implementation Date:** 2025-11-05 -**Status:** PRODUCTION READY -**Version:** 1.0.0 diff --git a/docs/changes/2024-admin-roadmap.md b/docs/changes/2024-admin-roadmap.md deleted file mode 100644 index c1eadc5..0000000 --- a/docs/changes/2024-admin-roadmap.md +++ /dev/null @@ -1,45 +0,0 @@ -# Admin Role & UI Implementation Plan - -Context: extend MotoVaultPro with an administrative user model, cross-tenant CRUD authority, and surfaced controls within the existing settings experience. Follow phases in order; each phase is shippable and assumes Docker-based validation per `CLAUDE.md`. - -## Phase 1 – Access Control Foundations -- Create `backend/src/features/admin/` capsule scaffolding (api/, domain/, data/, migrations/, tests/). -- Add migration `001_create_admin_users.sql` for table `admin_users (auth0_sub PK, email, created_at, created_by, revoked_at)`. -- Seed first record (`admin@motorvaultpro.com`, `created_by = system`) via migration or bootstrap script. -- Extend auth plugin flow to hydrate `request.userContext` containing `userId`, `email`, `isAdmin`, `adminRecord`. -- Add reusable guard `authorizeAdmin` in `backend/src/core/middleware/admin-guard.ts`; return 403 with `{ error: 'Forbidden', message: 'Admin access required' }`. -- Unit tests: guard behavior, context resolver, seed idempotency. - -## Phase 2 – Admin Management APIs -- Implement `/api/admin/admins` controller with list/add/revoke/reinstate endpoints; enforce “at least one active admin” rule in repository. -- Add audit logging via existing `logger` (log `actorAdminId`, `targetAdminId`, `action`, `context`). -- Provide read-only `/api/admin/users` for user summaries (reusing existing repositories, no data mutation yet). -- Integration tests validating: guard rejects non-admins, add admin, revoke admin while preventing last admin removal. - -## Phase 3 – Platform Catalog CRUD -- Add service `vehicleCatalog.service.ts` under admin feature to manage `vehicles.make|model|model_year|trim|engine|trim_engine`. -- Expose `/api/admin/catalog/...` endpoints for hierarchical CRUD; wrap mutations in transactions with referential validation. -- On write, call new cache helper in `backend/src/features/platform/domain/platform-cache.service.ts` to invalidate keys `platform:*`. -- Record admin change history in table `platform_change_log` (migration `002_create_platform_change_log.sql`). -- Tests: unit (service + cache invalidation), integration (create/update/delete + redis key flush assertions). - -## Phase 4 – Station Oversight -- Implement `/api/admin/stations` for global station CRUD and `/api/admin/users/:userId/stations` to manage saved stations. -- Ensure mutations update `stations` and `saved_stations` tables with soft delete semantics and invalidation of `stations:saved:{userId}` plus cached search keys. -- Provide optional `force=true` query to hard delete (document usage, default soft delete). -- Tests covering cache busting, permission enforcement, and happy-path CRUD. - -## Phase 5 – UI Integration (Settings-Based) -- Create hook `frontend/src/core/auth/useAdminAccess.ts` that calls `/auth/verify`, caches `isAdmin`, handles loading/error states. -- Desktop: update `frontend/src/pages/SettingsPage.tsx` to inject an “Admin Console” card when `isAdmin` true (links to admin subroutes) and display access CTA otherwise. -- Mobile: add admin section to `frontend/src/features/settings/mobile/MobileSettingsScreen.tsx` using existing `GlassCard` pattern; hide entirely for non-admins. -- Route stubs (e.g. `/garage/settings/admin/*`) should lazy-load forthcoming admin dashboards; guard them with `useAdminAccess`. -- Frontend tests (Jest/RTL) verifying conditional rendering on admin vs non-admin contexts. - -## Phase 6 – Quality Gates & Documentation -- Run backend/ frontend lint + tests inside containers (`make rebuild`, `make logs`, `make test-backend`, `docker compose exec mvp-frontend npm test`). -- Author `docs/ADMIN.md` summarizing role management workflow, API catalog, cache rules, and operational safeguards. -- Update existing docs (`docs/PLATFORM-SERVICES.md`, `docs/VEHICLES-API.md`, `docs/GAS-STATIONS.md`, `docs/README.md`) with admin references. -- Prepare release checklist: database migration order, seed verification for initial admin, smoke tests on both device classes (mobile + desktop), rollback notes. -- Confirm Traefik `/auth/verify` headers expose admin flag where needed for downstream services. - diff --git a/docs/changes/admin-implementation-plan.md b/docs/changes/admin-implementation-plan.md new file mode 100644 index 0000000..6608f56 --- /dev/null +++ b/docs/changes/admin-implementation-plan.md @@ -0,0 +1,1218 @@ +# Admin Settings Frontend Implementation Plan - Execution Document + +**Last Updated:** 2025-11-06 +**Status:** Ready for Parallel Execution +**Priority Order:** Vehicle Catalog → Station Oversight → Admin Users → Audit Logs + +--- + +## Executive Summary + +This document is the canonical execution plan for implementing the admin settings UI across desktop and mobile. It is designed to enable multiple AI agents to work in parallel on different phases. + +**Current State:** +- Frontend: All infrastructure (API hooks, types, auth gates) exists; pages are routed but use placeholder content +- Backend: Phase 0 requires implementation of 5 missing batch operation endpoints + +**Key Decisions:** +- Implement polling-based audit logs (5-second intervals) until SSE backend is ready +- Use client-side batch loops until backend batch endpoints exist +- All features MUST be implemented on both desktop and mobile +- Mobile routing integration is Phase 5 (after UI implementation) + +--- + +## Phase 0 - Backend Coordination (BLOCKING) + +**Status:** Investigation Complete +**Owner:** Backend Team (Coordination) + +### Required Backend Implementation + +The following endpoints are MISSING and must be created before bulk UI can be tested: + +``` +POST /api/admin/admins/bulk + Request: { admins: [{ email: string, role?: 'admin' | 'super_admin' }] } + Response: { created: AdminUser[], failed: { email: string, error: string }[] } + +PATCH /api/admin/admins/bulk-revoke + Request: { auth0Subs: string[] } + Response: { revoked: AdminUser[], failed: { auth0Sub: string, error: string }[] } + +PATCH /api/admin/admins/bulk-reinstate + Request: { auth0Subs: string[] } + Response: { reinstated: AdminUser[], failed: { auth0Sub: string, error: string }[] } + +DELETE /api/admin/catalog/{entity}/bulk-delete + Path: entity = 'makes' | 'models' | 'years' | 'trims' | 'engines' + Request: { ids: string[] } + Response: { deleted: string[], failed: { id: string, error: string }[] } + +DELETE /api/admin/stations/bulk-delete + Request: { ids: string[], force?: boolean } + Response: { deleted: string[], failed: { id: string, error: string }[] } + +GET /api/admin/audit-logs/stream (SSE) + Content-Type: text/event-stream + Payload: { id: string, data: AdminAuditLog } +``` + +### Interim Strategy + +Frontend agents can proceed with implementation using **client-side batch loops** against existing single-operation endpoints: +- `PATCH /api/admin/admins/:auth0Sub/revoke` (revoke loop) +- `DELETE /api/admin/catalog/makes/:makeId` (delete loop) +- `DELETE /api/admin/stations/:stationId` (delete loop) + +When backend batch endpoints are ready, frontend simply swaps the API calls without changing UI logic. + +### Polling Fallback for Audit Logs + +Until `GET /api/admin/audit-logs/stream` exists, use polling: +``` +GET /api/admin/audit-logs?limit=50&offset=0&sort=createdAt:desc +``` + +--- + +## Phase 1 - Vehicle Catalog (Priority 1) + +**Owner:** Any AI Agent +**Dependency:** None (uses existing hooks) +**Status:** Ready to Start + +### Overview + +Implement the vehicle catalog management interface for both desktop and mobile with hierarchical navigation, multi-select bulk delete, and cascading delete warnings. + +### Desktop UI - AdminCatalogPage.tsx + +**File:** `frontend/src/pages/admin/AdminCatalogPage.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Vehicle Catalog" + Stats Cards ──────────────────────┐ +│ Makes: 100 | Models: 500 | Years: 20 | Trims: 5000 | Engines: 8000 +├─ Main Content ──────────────────────────────────────────────────┤ +│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ Left Panel: Tree │ │ Right Panel: DataGrid │ │ +│ │ │ │ │ │ +│ │ [✓] Makes (100) │ │ □ Name │ Created │ Updated │ │ +│ │ ├─ [✓] Model (500) │ │ ─────────────────────────────────│ │ +│ │ │ ├─ Year (20) │ │ □ Honda │ 2024 │ 2025 │ │ +│ │ │ ├─ Trim (5000) │ │ □ Toyota │ 2024 │ 2025 │ │ +│ │ │ └─ Engine (8000) │ │ □ Ford │ 2024 │ 2025 │ │ +│ │ └─ [✓] Model 2 │ │ │ │ +│ │ │ │ [Add] [Edit] [Delete] [Bulk Del] │ │ +│ └──────────────────────┘ └──────────────────────────────────┘ │ +│ │ +│ ┌─ Audit Log Panel (Collapsible) ──────────────────────────────┐ +│ │ Catalog Changes (Last 50) [collapse] +│ │ ──────────────────────────────────────────────────────────── │ +│ │ 2025-11-06 14:32 | admin@example.com | Created Make | Honda │ +│ │ 2025-11-06 14:31 | admin@example.com | Deleted Make | Toyota │ +│ └────────────────────────────────────────────────────────────────┘ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Left panel: Hierarchical tree with expandable levels (Makes → Models → Years → Trims → Engines) +- Right panel: Data grid for selected level with columns: [checkbox], Name, Created, Updated, Actions +- Selection: + - Individual checkbox for each row + - "Select All" checkbox (selects across ALL pages, not just current page) +- Toolbar buttons: Add, Edit, Delete (single), Bulk Delete +- Breadcrumb: "Catalog > Makes > (Name)" to show current selection context +- Timestamps: Show created/updated times with formatting +- Audit log sidebar: Filtered to catalog-related actions only + +**Forms & Dialogs:** +- Create/Edit form modal with validation (name required, parent context shown) +- Confirm bulk delete dialog: "Delete X items? This will also delete Y dependent items. Continue?" +- Loading states and error displays + +**API Hooks to Use:** +```typescript +// From frontend/src/features/admin/hooks/useCatalog.ts +useMakes() / useCreateMake() / useUpdateMake() / useDeleteMake() +useModels() / useCreateModel() / useUpdateModel() / useDeleteModel() +useYears() / useCreateYear() / useDeleteYear() +useTrims() / useCreateTrim() / useUpdateTrim() / useDeleteTrim() +useEngines() / useCreateEngine() / useUpdateEngine() / useDeleteEngine() +``` + +**State Management:** +- Track selected level in URL or state (e.g., `?level=models&parentId=123`) +- Implement `useBulkSelection` hook for multi-select state +- Query invalidation strategy: Deleting a Make should invalidate Models, Years, Trims, Engines queries + +**Implementation Steps:** +1. Create shared admin components (Phase 1A) +2. Implement tree navigation panel +3. Implement data grid with selection +4. Wire forms and dialogs +5. Test all CRUD flows + +--- + +### Mobile UI - AdminCatalogMobileScreen.tsx + +**File:** `frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Catalog" + level indicator ─────────────────┐ +│ Makes (100) [filters] [sort] │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Card ──────────────────────────────────────────┐ │ +│ │ □ Honda Civic │ │ +│ │ Created: 2024-01-15 | Updated: 2025-11-01 │ │ +│ │ Models: 25 │ │ +│ │ [Edit] [Delete] [View Models →] │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Card ──────────────────────────────────────────┐ │ +│ │ □ Honda CR-V │ │ +│ │ Created: 2024-02-10 | Updated: 2025-11-02 │ │ +│ │ Models: 18 │ │ +│ │ [Edit] [Delete] [View Models →] │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +├─ Sticky Bottom Action Bar (Multi-Select Mode) ────────┤ +│ Selected: 3 [Delete] [Cancel] │ +└─────────────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Drill-down navigation: List → Select item → View children → Navigate deeper +- Card-based list showing entity name, metadata, count of children +- Long-press or "Select" button to enter multi-select mode +- Checkboxes appear in multi-select mode +- Sticky bottom action bar: "Selected: N | [Delete] [Cancel]" +- Quick action buttons: Edit, Delete (single), View Children +- Breadcrumb navigation at top showing: "Catalog > Makes > Honda > Models" +- Bottom sheet for bulk actions confirmation +- "Recent Changes" sheet: Tap icon to open audit log filtered to catalog +- Touch targets ≥44px + +**Mobile-Specific Patterns:** +- Avoid cramped UI - use bottom sheets for actions, not inline toolbars +- Swipe-to-delete option (optional, test in QA) +- Collapsible breadcrumb if needed to save space +- Loading skeleton cards + +**Implementation Steps:** +1. Build card component for catalog items +2. Implement drill-down navigation state +3. Add multi-select mode toggle +4. Wire sticky action bar +5. Add breadcrumb navigation +6. Integrate audit log bottom sheet + +--- + +### Shared Components & Hooks (Phase 1A) + +**Create these components first - they'll be reused in all phases:** + +#### Hooks + +**File:** `frontend/src/features/admin/hooks/useBulkSelection.ts` +```typescript +export function useBulkSelection(items: T[]) { + // Returns: { + // selected: Set, + // toggleItem(id: string): void, + // toggleAll(items: T[]): void, + // isSelected(id: string): boolean, + // reset(): void, + // count: number, + // } +} +``` + +**File:** `frontend/src/features/admin/hooks/useAuditLogStream.ts` +```typescript +export function useAuditLogStream(filters?: { + resourceType?: string; + actionType?: string; + limit?: number; +}) { + // Uses polling (GET /api/admin/audit-logs) until SSE available + // Returns: { + // logs: AdminAuditLog[], + // loading: boolean, + // error: any, + // pagination: { page: number, limit: number, total: number }, + // hasMore: boolean, + // nextPage(): void, + // prevPage(): void, + // refetch(): void, + // } +} +``` + +#### Components + +**File:** `frontend/src/features/admin/components/AdminSectionHeader.tsx` +- Props: title, stats (array of { label, value }) +- Displays: "Vehicle Catalog" with stats cards + +**File:** `frontend/src/features/admin/components/AdminDataGrid.tsx` +- Props: rows, columns, onSelectChange, selectedIds, loading, error, toolbar slot +- Features: Checkbox column, sortable headers, pagination controls +- Reusable for all data tables + +**File:** `frontend/src/features/admin/components/SelectionToolbar.tsx` +- Props: selectedCount, onSelectAll, onClear, children (action buttons) +- Displays: "Selected: N | [Select All] [Clear] + custom actions" + +**File:** `frontend/src/features/admin/components/BulkActionDialog.tsx` +- Props: open, title, message, items, onConfirm, onCancel, loading +- For confirming destructive bulk actions + +**File:** `frontend/src/features/admin/components/AuditLogPanel.tsx` (Desktop) +- Props: resourceType, filters +- Sidebar panel showing paginated audit logs +- Collapse/expand toggle + +**File:** `frontend/src/features/admin/components/AuditLogDrawer.tsx` (Mobile) +- Props: resourceType, filters, open, onClose +- Bottom sheet for mobile audit log view + +**File:** `frontend/src/features/admin/components/EmptyState.tsx` +- Props: icon, title, description, action? +- Reusable empty state display + +**File:** `frontend/src/features/admin/components/ErrorState.tsx` +- Props: error, onRetry +- Reusable error display with retry button + +**File:** `frontend/src/features/admin/components/AdminSkeleton.tsx` +- Loading skeleton cards/rows for all views + +--- + +### Bulk Delete Pattern (Interim - Client-Side Loops) + +Until backend batch endpoints exist, implement bulk operations like this: + +```typescript +async function bulkDeleteMakes(makeIds: string[]) { + const results = { deleted: [], failed: [] }; + + for (const makeId of makeIds) { + try { + await deleteM Make(makeId); + results.deleted.push(makeId); + } catch (error) { + results.failed.push({ makeId, error: error.message }); + } + } + + // Invalidate queries to refresh UI + queryClient.invalidateQueries({ queryKey: ['makes'] }); + queryClient.invalidateQueries({ queryKey: ['models'] }); // Cascade + + return results; +} +``` + +When backend batch endpoints are ready, swap to: +```typescript +const { deleted, failed } = await api.deleteMakesBulk(makeIds); +``` + +--- + +### Testing Checklist (Phase 1) + +- [ ] Tree navigation works (expand/collapse, selection) +- [ ] Data grid displays correctly with sorting +- [ ] Select All checkbox selects across all pages +- [ ] Bulk delete shows confirmation with cascade warning +- [ ] Single delete works with confirmation +- [ ] Create/Edit forms validate and submit +- [ ] Mobile drill-down navigation works smoothly +- [ ] Multi-select mode on mobile toggles correctly +- [ ] Audit log panel updates (or shows cached data) +- [ ] Loading/error states render properly +- [ ] Touch targets ≥44px on mobile +- [ ] Desktop ≥1280px and mobile ≤480px viewports tested + +--- + +## Phase 2 - Station Oversight (Priority 2) + +**Owner:** Any AI Agent +**Dependency:** Phase 1A components complete +**Status:** Ready to Start After Phase 1A + +### Overview + +Implement station management with soft/hard delete, restore functionality, and search/filter. + +### Desktop UI - AdminStationsPage.tsx + +**File:** `frontend/src/pages/admin/AdminStationsPage.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Station Management" + Stats ──────────────────────────┐ +│ Total: 500 | Active: 480 | Deleted: 20 | Archived: 0 │ +├─ Search & Filters ─────────────────────────────────────────────┤ +│ [Search by name...] [Active] [Deleted] [Archived] [✕ Clear All] │ +├─ DataGrid ─────────────────────────────────────────────────────┤ +│ □ Name │ Address │ Status │ Modified │ CreatedBy │ +│ ─────────────────────────────────────────────────────────────── │ +│ □ Shell Station │ 123 Main St │ Active │ 2025-11-01│ admin1 │ +│ □ BP Station │ 456 Oak Ave │ Deleted │ 2025-10-15│ admin2 │ +│ □ Chevron │ 789 Pine Rd │ Active │ 2025-11-03│ admin1 │ +│ │ +│ [Add Station] [Delete] [Restore] [Hard Delete] [Export CSV] │ +│ │ +├─ Detail Drawer (Right Side) ───────────────────────────────────┤ +│ Shell Station [Close] │ +│ ──────────────────────────────────────────────────────────── │ +│ Address: 123 Main St, Springfield, IL 62701 │ +│ Coordinates: 39.7817, -89.6501 │ +│ Status: Active │ +│ Created: 2024-01-15 by admin@example.com │ +│ Last Modified: 2025-11-01 by admin@example.com │ +│ [Edit] [Delete] [Hard Delete] │ +│ │ +├─ Audit Log Panel (Collapsible) ────────────────────────────────┤ +│ Station Events (Last 50) [collapse] │ +│ ────────────────────────────────────────────────────────────── │ +│ 2025-11-06 14:32 | admin1 | Hard deleted | Shell Station │ +│ 2025-11-06 14:31 | admin2 | Deleted | BP Station │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Search bar: Filter by station name +- Filter chips: Active, Deleted, Archived (soft-deleted showing as "Deleted") +- Multi-select with bulk delete (toggle soft/hard via dialog) +- Single-item quick actions: Delete, Restore (if deleted), Hard Delete +- Detail drawer on right showing full metadata +- Status column shows: Active, Deleted (soft), or Archived +- Toast notifications for hard deletes (warning) +- Audit log sidebar filtered to station events +- "Export CSV" button (stub for now) + +**Forms & Dialogs:** +- Create/Edit station form (name, address, coordinates) +- Confirm soft delete dialog: "This will soft-delete the station. Data can be restored." +- Confirm hard delete dialog: "This will permanently delete the station and cannot be undone. Continue?" +- Confirm restore dialog: "Restore this station to active?" + +**API Hooks:** +```typescript +useStationOverview() / useCreateStation() / useUpdateStation() / useDeleteStation() +``` + +**Status & Delete Logic:** +- `deletedAt === null` → "Active" +- `deletedAt !== null` → "Deleted" (soft delete) +- Hard delete: Pass `?force=true` to delete endpoint + +**Implementation Steps:** +1. Build data grid with search and filter chips +2. Implement detail drawer +3. Add create/edit form modal +4. Wire soft/hard delete dialogs +5. Add restore functionality +6. Integrate audit log panel +7. Test all flows + +--- + +### Mobile UI - AdminStationsMobileScreen.tsx + +**File:** `frontend/src/features/admin/mobile/AdminStationsMobileScreen.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Stations" + Filters ────────────────────┐ +│ Filter: [All] [Active] [Deleted] [sort by ▼] │ +├───────────────────────────────────────────────────┤ +│ │ +│ ┌─ Card ──────────────────────────────────────┐ │ +│ │ Shell Station [status: Active]│ +│ │ 123 Main St, Springfield, IL 62701 │ │ +│ │ Created: 2024-01-15 | Modified: 2025-11-01 │ │ +│ │ [Edit] [Delete] [Restore] [Details →] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Card ──────────────────────────────────────┐ │ +│ │ BP Station [status: Deleted] │ +│ │ 456 Oak Ave, Shelbyville, IL 62702 │ │ +│ │ Created: 2024-02-20 | Modified: 2025-10-15 │ │ +│ │ [Edit] [Delete] [Restore] [Details →] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +├─ Sticky Bottom Action Bar (Multi-Select Mode) ──┤ +│ Selected: 2 [Delete] [Cancel] │ +└───────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Card-based list with station name, address, status badge +- Filter tabs: All / Active / Deleted +- Multi-select mode via long-press or "Select" button +- Sticky bottom action bar when items selected +- Quick action buttons: Edit, Delete, Restore, Details +- Details bottom sheet: Full metadata and actions +- Audit log bottom sheet: "Recent Changes" showing station events +- Touch targets ≥44px + +**Implementation Steps:** +1. Build card component for stations +2. Add filter tab navigation +3. Implement multi-select mode +4. Wire sticky action bar +5. Create details bottom sheet +6. Add edit form modal +7. Integrate audit log + +--- + +### Testing Checklist (Phase 2) + +- [ ] Search filters stations correctly +- [ ] Filter chips work (Active, Deleted, Archived) +- [ ] Multi-select and bulk delete work +- [ ] Soft delete works (status changes to "Deleted") +- [ ] Restore works (status back to "Active") +- [ ] Hard delete confirms and removes permanently +- [ ] Detail drawer shows correct metadata +- [ ] Create/Edit forms validate +- [ ] Audit log filters to station events +- [ ] Toast shows for hard deletes +- [ ] Mobile filter tabs work +- [ ] Mobile quick actions respond correctly +- [ ] Desktop and mobile tested at stated breakpoints + +--- + +## Phase 3 - Admin Users (Priority 3) + +**Owner:** Any AI Agent +**Dependency:** Phase 1A components + Phase 2 complete +**Status:** Ready to Start After Phase 2 + +### Overview + +Implement admin user management with invite, revoke, reinstate, and deletion with "last admin" protection. + +### Desktop UI - AdminUsersPage.tsx + +**File:** `frontend/src/pages/admin/AdminUsersPage.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Admin Users" + Stats ────────────────────────────────┐ +│ Total: 5 | Active: 4 | Revoked: 1 │ +├─ Search & Actions ────────────────────────────────────────────┤ +│ [Search by email...] [Invite Admin] [Revoke] [Reinstate] [Del] │ +├─ DataGrid ────────────────────────────────────────────────────┤ +│ □ Email │ Role │ Status │ Created │ Last │ +│ ─────────────────────────────────────────────────────────────── │ +│ □ alice@example.com │ super_admin│ Active │ 2024-01-01│ now │ +│ □ bob@example.com │ admin │ Active │ 2024-02-15│ 1h │ +│ □ charlie@example.com│ admin │ Revoked │ 2024-03-01│ 5d │ +│ □ diana@example.com │ admin │ Active │ 2025-01-01│ now │ +│ │ +├─ Audit Log Panel (Collapsible) ───────────────────────────────┤ +│ Admin Actions (Last 50) [collapse] │ +│ ────────────────────────────────────────────────────────────── │ +│ 2025-11-06 14:32 | alice | Revoked admin | charlie@example.com │ +│ 2025-11-06 14:15 | alice | Invited admin | diana@example.com │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Search bar: Filter by email +- Toolbar actions: Invite Admin, Revoke (bulk), Reinstate (bulk), Delete (bulk) +- Multi-select with confirmation dialogs +- Status column: Active or Revoked +- Role column: admin or super_admin +- Last Activity column: Relative time (e.g., "5m ago", "2d ago") +- Last Admin Protection: Show error if trying to revoke/delete the last active admin +- Bulk action confirmation: "Revoke N admins? They will lose access immediately." +- Audit log sidebar: Filtered to admin actions + +**Forms & Dialogs:** +- Invite Admin modal: + - Email field (required, validated) + - Role dropdown (admin / super_admin) + - Submit button with loading state +- Revoke Confirmation dialog: Show who will be revoked, warn if last admin +- Reinstate Confirmation dialog: Confirm reinstate +- Delete Confirmation dialog: "Delete N admins? This cannot be undone." + +**API Hooks:** +```typescript +useAdmins() / useCreateAdmin() / useRevokeAdmin() / useReinstateAdmin() +``` + +**Last Admin Logic:** +- Before revoke/delete, check if admin is the last active one +- If revoke: Show error "Cannot revoke the last active admin" +- UI should disable action or show clear message + +**Implementation Steps:** +1. Build data grid with email search +2. Implement invite form modal +3. Add revoke/reinstate/delete confirmation dialogs +4. Wire last admin protection +5. Add bulk action handling +6. Integrate audit log panel +7. Test all flows including last admin edge case + +--- + +### Mobile UI - AdminUsersMobileScreen.tsx + +**File:** `frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx` (currently placeholder) + +**Layout:** +``` +┌─ Header: "Admins" + Filter ────────────────────────┐ +│ Filter: [All] [Active] [Revoked] [Invite + ] │ +├────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Card ────────────────────────────────────────┐ │ +│ │ alice@example.com [super_admin] │ +│ │ Status: Active | Created: 2024-01-01 │ +│ │ Last Activity: Now │ +│ │ [Revoke] [Delete] [Details →] │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Card ────────────────────────────────────────┐ │ +│ │ bob@example.com [admin] │ +│ │ Status: Active | Created: 2024-02-15 │ +│ │ Last Activity: 1h ago │ +│ │ [Revoke] [Delete] [Details →] │ +│ └────────────────────────────────────────────────┘ │ +│ │ +├─ Sticky Bottom Action Bar (Multi-Select Mode) ───┤ +│ Selected: 2 [Revoke] [Delete] [Cancel] │ +└────────────────────────────────────────────────────┘ +``` + +**Key Features:** +- Card-based list: Email, role badge, status, created date, last activity +- Filter tabs: All / Active / Revoked +- "Invite +" button in header for quick access +- Multi-select via long-press or "Select" button +- Sticky bottom action bar: Revoke, Delete, Cancel +- Quick action buttons: Revoke, Delete, Details +- Details bottom sheet: Full metadata, role, created by, last activity +- Invite modal: Email + role selection +- Confirmation for revoke/delete +- Last admin protection warning +- Touch targets ≥44px + +**Implementation Steps:** +1. Build card component for admins +2. Add filter tab navigation +3. Wire invite form modal +4. Implement multi-select mode +5. Add sticky action bar with revoke/delete +6. Create details bottom sheet +7. Add confirmation dialogs with last admin check +8. Integrate audit log + +--- + +### Testing Checklist (Phase 3) + +- [ ] Search filters admins by email +- [ ] Invite form validates email and role +- [ ] Invite submits successfully +- [ ] Revoke shows confirmation and updates status +- [ ] Reinstate shows confirmation and updates status +- [ ] Delete confirms and removes admin +- [ ] Last admin protection prevents revoke/delete (shows error) +- [ ] Bulk operations work with confirmation +- [ ] Audit log filters to admin actions +- [ ] Mobile filter tabs and quick actions work +- [ ] Details bottom sheet shows full info +- [ ] Desktop and mobile tested at stated breakpoints +- [ ] Role badges display correctly (admin vs super_admin) + +--- + +## Phase 4 - Audit Log Streaming (Priority 4) + +**Owner:** Any AI Agent +**Dependency:** Phases 1-3 complete +**Status:** Ready to Start After Phase 3 + +### Overview + +Implement real-time (polling-based) audit log display across all admin pages with filtering and search. + +### Implementation Strategy + +**Polling Approach (Until SSE Backend):** +- Use `GET /api/admin/audit-logs?limit=50&offset=0&sort=createdAt:desc` +- Poll every 5 seconds +- Cache results in React Query +- Show "Last updated: X seconds ago" + +**Hook Implementation** + +**File:** `frontend/src/features/admin/hooks/useAuditLogStream.ts` + +```typescript +interface AuditLogStreamOptions { + resourceType?: 'admin' | 'catalog' | 'station'; + actionType?: 'CREATE' | 'UPDATE' | 'DELETE' | 'REVOKE' | 'REINSTATE'; + limit?: number; + pollIntervalMs?: number; +} + +export function useAuditLogStream(options: AuditLogStreamOptions = {}) { + // Returns: { + // logs: AdminAuditLog[], + // loading: boolean, + // error: any, + // pagination: { offset: number, limit: number, total: number }, + // hasMore: boolean, + // nextPage(): void, + // prevPage(): void, + // refetch(): void, + // lastUpdated: Date, + // } +} +``` + +**Component Integration** + +Update `AuditLogPanel.tsx` and `AuditLogDrawer.tsx` to use the hook: + +```typescript +// Desktop + refetch()} + logs={logs} + loading={loading} + pagination={pagination} + onNextPage={nextPage} +/> + +// Mobile + +``` + +**Audit Log Table Component** + +**File:** `frontend/src/features/admin/components/AuditLogTable.tsx` + +```typescript +interface AuditLogTableProps { + logs: AdminAuditLog[]; + loading?: boolean; + pagination?: PaginationState; + onNextPage?: () => void; + onPrevPage?: () => void; + filters?: { + actor?: string; + action?: string; + resource?: string; + }; + onFilterChange?: (filters: any) => void; +} + +// Display columns: Timestamp | Actor | Action | Resource | Details +// Sortable by timestamp +// Paginated with prev/next buttons +// Show "Last updated: 5s ago" +``` + +**Filter Implementation** + +Add filter controls to audit log UI: +- Actor (actor admin email) +- Action (CREATE, DELETE, REVOKE, etc.) +- Resource Type (admin, make, station, etc.) +- Date range picker (optional, start with basic) + +**Integration Across Pages** + +Update Phase 1-3 pages to wire audit logs: + +1. **AdminCatalogPage:** Filter `resourceType: 'catalog'` +2. **AdminStationsPage:** Filter `resourceType: 'station'` +3. **AdminUsersPage:** Filter `resourceType: 'admin'` + +--- + +### Testing Checklist (Phase 4) + +- [ ] useAuditLogStream polls correctly every 5s +- [ ] Logs display in correct time order (newest first) +- [ ] Filters work (resourceType, actionType, actor) +- [ ] Pagination controls work (next/prev) +- [ ] "Last updated" timestamp shows current time +- [ ] Loading state shows skeleton +- [ ] Error state shows with retry button +- [ ] Mobile drawer opens/closes +- [ ] Desktop panel collapses/expands +- [ ] Real-time updates visible when actions performed on other tabs +- [ ] Audit log accessible only to admins (no leakage) + +--- + +## Phase 5 - Mobile Routing Integration + +**Owner:** Any AI Agent +**Dependency:** Phases 1-4 complete +**Status:** Ready to Start After Phase 4 + +### Overview + +Wire mobile admin screens into the mobile navigation system. + +**Current Mobile Navigation:** +- Bottom navigation bar with fixed items: Dashboard, Vehicles, Log Fuel, Stations, Documents, Settings +- Admin screens currently exist but are not routable + +**Changes Required:** + +1. **Create Route Wrappers** (Optional but Recommended) + +**File:** `frontend/src/features/admin/routes/AdminCatalogRoute.tsx` + +```typescript +export function AdminCatalogRoute() { + const { isMobile } = useMediaQuery(); + + return isMobile ? : ; +} +``` + +Repeat for `AdminStationsRoute` and `AdminUsersRoute`. + +2. **Update Mobile Navigation** + +**File:** `frontend/src/components/MobileBottomNav.tsx` (or similar) + +Add admin access check and menu expansion: + +```typescript +const { isAdmin } = useAdminAccess(); + +// In bottom nav items: +{ + isAdmin && ( + }> + + Catalog + Stations + Users + + + ) +} +``` + +Alternative (simpler): Add "Admin" to Settings submenu. + +3. **Update Mobile Routes** + +**File:** `frontend/src/routes.tsx` or router configuration + +```typescript +{ + path: '/admin/catalog', + element: , + private: true, + requiresAdmin: true, +}, +{ + path: '/admin/stations', + element: , + private: true, + requiresAdmin: true, +}, +{ + path: '/admin/users', + element: , + private: true, + requiresAdmin: true, +}, +``` + +4. **Verify Route Protection** + +- All admin routes should check `useAdminAccess()` and redirect to `/garage/settings` if not admin +- Routes should not render until auth check completes + +**Implementation Steps:** +1. Create route wrapper components +2. Update mobile bottom navigation +3. Add routes to router configuration +4. Test navigation on mobile viewport +5. Verify auth protection + +--- + +### Testing Checklist (Phase 5) + +- [ ] Admin menu items visible to admins only +- [ ] Route navigation works on mobile +- [ ] Back button returns to previous screen +- [ ] Auth redirects non-admins correctly +- [ ] All 3 admin screens accessible from mobile nav +- [ ] Returning to mobile nav from admin screen works +- [ ] Viewport ≤480px tested + +--- + +## Phase 6 - Testing & QA + +**Owner:** Any AI Agent (or dedicated QA) +**Dependency:** All phases 1-5 complete +**Status:** Ready to Start After Phase 5 + +### Unit Tests + +**File:** `frontend/src/features/admin/__tests__/` + +Test new hooks: +```typescript +// useBulkSelection.test.ts +- Selecting/deselecting items +- Select all / clear all +- Toggle item +- Reset state + +// useAuditLogStream.test.ts +- Polling interval (mock timer) +- Parsing paginated response +- Filter by resourceType +- Pagination state updates +- Error handling and retry +``` + +**Test Setup:** +- Mock API calls using MSW (Mock Service Worker) +- Mock React Query cache +- Use `renderHook` from testing library +- Mock EventSource for SSE upgrade testing + +### Component Tests + +**File:** `frontend/src/features/admin/__tests__/` + +Desktop components: +```typescript +// AdminDataGrid.test.tsx +- Render rows and columns +- Select/deselect rows +- Select all checkbox +- Toolbar slot renders +- Sorting works +- Pagination controls + +// BulkActionDialog.test.tsx +- Confirm button triggers callback +- Cancel closes dialog +- Loading state disables buttons +- Message displays correctly + +// AdminCatalogPage.test.tsx +- Tree navigation expands/collapses +- Grid updates when level selected +- Create form validates +- Delete shows confirmation +- Bulk delete loops through items +``` + +Mobile components: +```typescript +// AdminCatalogMobileScreen.test.tsx +- Card list renders +- Drill-down navigation +- Multi-select mode toggle +- Bottom sheet opens/closes +- Breadcrumb updates + +// AuditLogDrawer.test.tsx +- Opens/closes +- Displays logs +- Pagination works +``` + +**Test Coverage Goal:** >80% for admin feature + +### Integration Tests + +**File:** `frontend/src/features/admin/__tests__/integration/` + +End-to-end flows: +```typescript +describe('Admin Catalog E2E', () => { + test('Select multiple makes, confirm delete, verify API called', async () => { + // 1. Render AdminCatalogPage + // 2. Select 3 makes via checkboxes + // 3. Click Bulk Delete button + // 4. Confirm in dialog + // 5. Verify deleteM ake() called 3 times + // 6. Verify query invalidated + // 7. Verify grid updated + }); + + test('Cascade warning shows when deleting make with models', async () => { + // 1. Select make that has 5 models + // 2. Click delete + // 3. Confirm dialog shows "This will delete 5 models" + }); +}); + +describe('Admin Users E2E', () => { + test('Last admin protection prevents revoke', async () => { + // 1. Try to revoke last active admin + // 2. Show error message + // 3. Button disabled or error shown + }); + + test('Bulk revoke multiple admins', async () => { + // 1. Select 3 non-last admins + // 2. Click Bulk Revoke + // 3. Confirm dialog + // 4. Verify all revoked + }); +}); + +describe('Admin Stations E2E', () => { + test('Soft delete then restore station', async () => { + // 1. Delete station + // 2. Verify status is "Deleted" + // 3. Click restore + // 4. Verify status is "Active" + }); + + test('Hard delete shows warning', async () => { + // 1. Click hard delete + // 2. Confirm shows warning + // 3. After delete, toast shows critical warning + }); +}); +``` + +### Manual QA Matrix + +**Test Environments:** +- Desktop: Chrome, Firefox, Safari at ≥1280px +- Mobile: iOS Safari, Android Chrome at ≤480px +- Tablet: iPad at 768px (optional) + +**Critical Flows to Test:** + +| Flow | Desktop | Mobile | Notes | +|------|---------|--------|-------| +| Invite admin, verify in list | ✓ | ✓ | Check email sends (stub for now) | +| Revoke admin, verify status | ✓ | ✓ | Check audit log shows action | +| Bulk revoke 3 admins | ✓ | ✓ | Confirm all revoked | +| Last admin protection | ✓ | ✓ | Try to revoke/delete last admin | +| Create catalog make | ✓ | ✓ | Check hierarchy updates | +| Bulk delete 5 makes | ✓ | ✓ | Check models/years cascade invalidated | +| Cascade warning on delete | ✓ | ✓ | Delete make with 10 models, show warning | +| Delete station (soft) | ✓ | ✓ | Check status changes to "Deleted" | +| Restore station | ✓ | ✓ | Check status back to "Active" | +| Hard delete station | ✓ | ✓ | Check permanent removal, toast warning | +| Audit log filters | ✓ | ✓ | Filter by resource type, action | +| Audit log pagination | ✓ | ✓ | Page through logs | +| Mobile navigation | - | ✓ | Admin menu access from settings | +| Search/filter on desktop | ✓ | - | Catalog search, station search | + +**Accessibility Checklist:** +- [ ] Touch targets ≥44px on mobile +- [ ] Keyboard navigation on desktop (Tab, Enter, Esc) +- [ ] Focus indicators visible +- [ ] Color contrast WCAG AA compliant +- [ ] Screen reader compatible (alt text, labels, ARIA) +- [ ] No console errors or warnings + +### Browser Compatibility + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Mobile Safari (iOS 14+) +- Android Chrome 90+ + +--- + +## Parallel Execution Strategy + +This document enables parallel development by multiple AI agents. Here's how: + +### Team 1: Shared Components (Phase 1A) +- Build all reusable components +- Types and hooks +- Expected output: Reviewed and merged shared component library + +### Team 2: Vehicle Catalog (Phase 1) +- Desktop and mobile implementation +- Depends on Phase 1A +- Parallel with Team 3 after Phase 1A complete + +### Team 3: Station Oversight (Phase 2) +- Desktop and mobile implementation +- Depends on Phase 1A +- Can start while Team 2 finishes Phase 1 + +### Team 4: Admin Users (Phase 3) +- Desktop and mobile implementation +- Depends on Phases 1-2 +- Starts when Teams 2-3 complete + +### Team 5: Audit Logs & Mobile Routing (Phases 4-5) +- Integrates polling + streaming +- Mobile routing setup +- Depends on Phases 1-3 +- Can start when Teams 2-4 in progress + +### Team 6: Testing & QA (Phase 6) +- Unit and integration tests +- Manual QA matrix +- Runs in parallel with Teams 2-5 after their components merge +- Creates final sign-off + +--- + +## Success Criteria + +All phases complete when: + +- ✅ All linters pass (ESLint, TypeScript) - Zero issues +- ✅ All tests pass (>80% coverage for admin feature) +- ✅ Feature works end-to-end on desktop (≥1280px) and mobile (≤480px) +- ✅ Old placeholder code deleted +- ✅ No console errors or warnings +- ✅ Manual QA matrix completed +- ✅ Code reviewed and merged to main + +--- + +## Known Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Backend batch endpoints delayed | Use client-side loops (already implemented pattern); swap endpoints when ready | +| SSE not implemented | Poll audit logs every 5s; upgrade when backend ready; feature flag if needed | +| Mobile routing not clear | Implement route wrappers with `useMediaQuery` for viewport detection | +| Query invalidation cache issues | Use structured queryKeys; test cascade deletes thoroughly | +| Performance with large lists | Implement virtualization if >500 items; use pagination | +| Last admin constraint edge case | Disable action and show clear error message; test thoroughly | +| Touch targets <44px on mobile | Verify all buttons/interactable elements in QA phase | + +--- + +## Questions / Blockers + +If any AI agent encounters: +- Missing API endpoint → Document in Phase 0 summary; use interim workaround +- Component design unclear → Reference existing designs in codebase +- Type mismatch → Check `admin.types.ts` for canonical types +- Routing issues → Check `src/routes.tsx` for routing pattern + +--- + +## Appendix: File Checklist + +### New Files to Create + +``` +frontend/src/features/admin/components/ + ├── AdminSectionHeader.tsx + ├── AdminDataGrid.tsx + ├── SelectionToolbar.tsx + ├── BulkActionDialog.tsx + ├── AuditLogPanel.tsx (Desktop) + ├── AuditLogDrawer.tsx (Mobile) + ├── AuditLogTable.tsx + ├── EmptyState.tsx + ├── ErrorState.tsx + └── AdminSkeleton.tsx + +frontend/src/features/admin/hooks/ + ├── useBulkSelection.ts (NEW) + └── useAuditLogStream.ts (NEW - extends existing hooks) + +frontend/src/features/admin/routes/ (Optional, Phase 5) + ├── AdminCatalogRoute.tsx + ├── AdminStationsRoute.tsx + └── AdminUsersRoute.tsx + +frontend/src/features/admin/__tests__/ + ├── useBulkSelection.test.ts + ├── useAuditLogStream.test.ts + ├── AdminDataGrid.test.tsx + ├── BulkActionDialog.test.tsx + ├── AdminCatalogPage.test.tsx + ├── AdminCatalogMobileScreen.test.tsx + ├── AdminStationsPage.test.tsx + ├── AdminStationsMobileScreen.test.tsx + ├── AdminUsersPage.test.tsx + ├── AdminUsersMobileScreen.test.tsx + └── integration/ + ├── catalog.e2e.test.ts + ├── stations.e2e.test.ts + └── users.e2e.test.ts +``` + +### Files to Update + +``` +frontend/src/pages/admin/ + ├── AdminCatalogPage.tsx (Replace placeholder) + ├── AdminStationsPage.tsx (Replace placeholder) + └── AdminUsersPage.tsx (Replace placeholder) + +frontend/src/features/admin/mobile/ + ├── AdminCatalogMobileScreen.tsx (Replace placeholder) + ├── AdminStationsMobileScreen.tsx (Replace placeholder) + └── AdminUsersMobileScreen.tsx (Replace placeholder) + +frontend/src/routes.tsx (Add mobile admin routes, Phase 5) + +frontend/src/components/MobileBottomNav.tsx (Add admin menu, Phase 5) +``` + +### Existing Files (No Changes) + +``` +frontend/src/features/admin/api/admin.api.ts (Ready to use) +frontend/src/features/admin/hooks/useAdmins.ts (Ready to use) +frontend/src/features/admin/hooks/useCatalog.ts (Ready to use) +frontend/src/features/admin/hooks/useStationOverview.ts (Ready to use) +frontend/src/features/admin/types/admin.types.ts (Ready to use) +frontend/src/core/auth/useAdminAccess.ts (Ready to use) +``` + +--- + +## Document History + +| Date | Author | Changes | +|------|--------|---------| +| 2025-11-06 | AI Planning | Initial creation for parallel execution | + +--- + +**Next Step:** Assign agents to Phase 1A (Shared Components) to unblock all subsequent phases. diff --git a/docs/changes/admin-settings-frontend-plan.md b/docs/changes/admin-settings-frontend-plan.md new file mode 100644 index 0000000..857e8bf --- /dev/null +++ b/docs/changes/admin-settings-frontend-plan.md @@ -0,0 +1,134 @@ +# Admin Settings Frontend Implementation Plan + +## Audience & Scope +- **Intended executor**: AI agent implementing MotoVaultPro admin settings UI across desktop and mobile. +- **Scope**: Frontend-only tasks within `frontend/`, coordinating with existing backend admin APIs. Includes real-time audit log integration and bulk operations across admin users, catalog entities, and station management. + +## Current State Summary +- Routes exist (`frontend/src/pages/admin/*.tsx`, `frontend/src/features/admin/mobile/*.tsx`) but contain placeholder copy. +- Hooks and API clients (`frontend/src/features/admin/hooks/*`, `frontend/src/features/admin/api/admin.api.ts`) already wrap CRUD endpoints but lack bulk helpers and streaming. +- Settings pages link into admin routes; `useAdminAccess` gate is wired. +- No shared admin layout, tables, or selection utilities; no real-time audit consumption; no bulk UI. + +## Key Requirements +1. **Real-time audit logging** for admin operations (desktop + mobile). +2. **Bulk operations**: multi-select + batch mutate/delete/revoke across admin users, catalog hierarchy, stations. +3. **Desktop / Mobile parity** while respecting CLAUDE.md mobile + desktop mandate and existing design system. + +Assumptions: +- Backend will expose streaming endpoint (`/api/admin/audit-logs/stream`) using SSE. (If absent, coordinate for addition.) +- Backend will provide/extend batch mutation endpoints or accept arrays in current ones. +- No additional design assets; follow existing Material UI / GlassCard patterns. + +## Implementation Phases + +### Phase 0 – Prep & Validation +- Confirm backend endpoints: + - `GET /api/admin/audit-logs/stream` (SSE) payload schema. + - Batch endpoints for admins (`POST /admin/admins/bulk`, `PATCH /admin/admins/bulk-revoke`, etc.), catalog (`/admin/catalog/{entity}/bulk-delete`), stations (`/admin/stations/bulk-delete`). + - Response format + error contracts. +- Document agreements in `docs/ADMIN.md` and update API client typings before UI work. + +### Phase 1 – Shared Infrastructure +- Add shared admin components under `frontend/src/features/admin/components/`: + - `AdminSectionHeader` + - `AdminDataGrid` (wrapper around MUI DataGrid or Table) with checkbox selection and toolbar slot. + - `SelectionToolbar` + `BulkActionDialog`. + - `AuditLogPanel` (desktop) and `AuditLogDrawer` (mobile). + - `EmptyState`, `ErrorState`, `Skeleton` variants. +- Utility hooks/services: + - `useBulkSelection` (manages item selection, select all, reset). + - `useAuditLogStream` (SSE handling, merge into cache, pause/resume). + - `useAdminRealtimeEffect` (common real-time logic for both platforms). + - Error normalization helper for API responses. +- Update `admin.api.ts` to include bulk endpoints and streaming subscription helper. + - Ensure types in `admin.types.ts` cover new request/response payloads. + +### Phase 2 – Admin Users Experience +- **Desktop (`AdminUsersPage.tsx`)**: + - Replace placeholder with layout: + - Header (stats summary cards). + - `AdminDataGrid` listing admins (columns: email, role, status, created/updated, last activity). + - Toolbar actions: Invite, Revoke, Reinstate, Delete (single + bulk), export CSV placeholder. + - Inline filters/search. + - Audit log side panel fed by `useAuditLogStream`. + - Modals/forms: + - Invite admin (react-hook-form + Zod validation). + - Confirm dialogs for revoke/reinstate/delete (bulk friendly). + - State management: + - Use React Query hooks (`useAdmins`, new `useBulkRevokeAdmins`, etc.). + - Optimistic updates where safe; fallback to refetch on failure. + - Surface backend constraints (last admin protection) in toasts/dialogs. +- **Mobile (`AdminUsersMobileScreen.tsx`)**: + - Card-based list with segmented controls. + - Multi-select mode triggered by long-press or “Select” button; sticky bottom action bar for bulk operations. + - Slide-in drawer for audit log stream; allow collapse to preserve screen space. + - Ensure loading/error/empty states match mobile pattern. + +### Phase 3 – Vehicle Catalog Management +- Extend API hooks for per-entity bulk operations (`useDeleteMakesBulk`, etc.) and streaming updates. +- **Desktop (`AdminCatalogPage.tsx`)**: + - Two-column layout: left panel shows hierarchical tree (Makes → Models → Years → Trims → Engines). Right panel shows detail grid for selected level. + - Support multi-select in each grid with bulk delete; confirm cascading impacts (warn when deleting parents). + - Modals for create/edit per entity using shared form component (with validation & parent context). + - Audit log panel filtered to catalog-related actions. + - Show breadcrumbs + context metadata (created/updated timestamps). +- **Mobile (`AdminCatalogMobileScreen.tsx`)**: + - Drill-down navigation (list of makes → models → ...). + - Selection mode toggles for bulk delete at current depth; use bottom sheet to display actions. + - Provide “Recent Changes” sheet consuming audit stream (filtered). +- Handle cache invalidation across hierarchies (e.g., deleting a make invalidates models/years/trims queries). Consider using queryClient `invalidateQueries` with partial keys. + +### Phase 4 – Station Oversight +- Hook updates: add `useBulkDeleteStations`, `useBulkRestoreStations` if available, with optional `force` flag. +- **Desktop (`AdminStationsPage.tsx`)**: + - Data grid with columns (name, address, status, last modified, createdBy). Add search bar and filter chips (active, soft-deleted). + - Bulk selection with delete (soft/hard toggle), restore, export stub. + - Station detail drawer with metadata and quick actions. + - Audit log panel focusing on station events; highlight critical operations via toast (e.g., hard deletes). +- **Mobile (`AdminStationsMobileScreen.tsx`)**: + - Card list with quick actions (edit, delete, restore). Multi-select mode with sticky action bar. + - Provide filter tabs (All / Active / Deleted). + - Integrate audit log bottom sheet. + +### Phase 5 – Integration & Routing Enhancements +- Introduce route wrapper/components (e.g., `AdminUsersRoute`) that detect viewport using `useMediaQuery` and render desktop or mobile variant; ensures shared logic and prevents duplicate routing code. +- Update navigation flows, ensuring mobile bottom navigation can reach admin sections gracefully. +- Document keyboard shortcuts or focus management for accessibility (bulk selection, audit log toggles). + +### Phase 6 – Testing & QA +- Add unit tests for new hooks (`useAuditLogStream`, bulk hooks) using Jest + Testing Library. Mock EventSource for streaming tests. +- Component tests: + - Desktop grids: selection toggles, bulk action dialogs, form validation. + - Mobile screens: selection mode toggling, action bar behaviors. + - Audit log panels: streaming update rendering, pause/resume controls. +- Visual regression smoke tests if tooling available; otherwise document manual screenshot checkpoints. +- Manual QA matrix: + - Desktop ≥1280px and mobile ≤480px. + - Test flows: invite admin, revoke/reinstate, bulk revoke, catalog cascading delete, station soft/hard delete, audit log live updates. + +## Deliverables Checklist +- [ ] Updated API client + types for batch + streaming. +- [ ] Shared admin UI components & utilities. +- [ ] Desktop admin pages fully functional with bulk + real-time features. +- [ ] Mobile admin screens matching functionality. +- [ ] Comprehensive tests covering new flows. +- [ ] Documentation updates (API usage, manual QA steps). + +## Risks & Mitigations +- **Streaming availability**: If backend stream not ready, fall back to polling with progressive enhancement; keep SSE integration behind feature flag. +- **Bulk API inconsistencies**: Align payload format with backend; add defensive UI (disable actions until backend confirms support). +- **State synchronization**: Ensure query invalidation covers dependent entities; consider structured query keys and `queryClient.setQueryData` for incremental updates. +- **Mobile UX complexity**: Prototype selection mode early to validate ergonomics; leverage bottom sheets to avoid cramped toolbars. + +## Follow-up Questions (Resolved) +1. Real-time audit logs required — implement SSE-based stream handling. +2. Bulk operations mandatory — support multi-select + batch actions across admin users, catalog entities, stations. +3. No additional design constraints — rely on existing Material UI and GlassCard paradigms. + +## Handoff Notes +- Keep code comments concise per developer guidelines; avoid introducing new design systems. +- Validate hooks for Auth0 dependency (ensure disabled when unauthenticated). +- Coordinate with backend team if API gaps found; document interim shims. +- Maintain responsiveness and accessibility; ensure touch targets ≥44px and keyboard operability on desktop grids. + diff --git a/frontend/src/features/admin/__tests__/catalogShared.test.ts b/frontend/src/features/admin/__tests__/catalogShared.test.ts new file mode 100644 index 0000000..0520788 --- /dev/null +++ b/frontend/src/features/admin/__tests__/catalogShared.test.ts @@ -0,0 +1,134 @@ +import { + getCascadeSummary, + CatalogSelectionContext, +} from '../catalog/catalogShared'; +import { buildDefaultValues } from '../catalog/catalogSchemas'; +import { + CatalogEngine, + CatalogMake, + CatalogModel, + CatalogTrim, + CatalogYear, +} from '../types/admin.types'; + +const baseMake: CatalogMake = { + id: 'make-1', + name: 'Honda', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +const baseModel: CatalogModel = { + id: 'model-1', + makeId: baseMake.id, + name: 'Civic', + createdAt: '2024-01-02T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', +}; + +const baseYear: CatalogYear = { + id: 'year-1', + modelId: baseModel.id, + year: 2024, + createdAt: '2024-01-03T00:00:00Z', + updatedAt: '2024-01-03T00:00:00Z', +}; + +const baseTrim: CatalogTrim = { + id: 'trim-1', + yearId: baseYear.id, + name: 'Sport', + createdAt: '2024-01-04T00:00:00Z', + updatedAt: '2024-01-04T00:00:00Z', +}; + +const baseEngine: CatalogEngine = { + id: 'engine-1', + trimId: baseTrim.id, + name: '2.0T', + displacement: '2.0L', + cylinders: 4, + fuel_type: 'Gasoline', + createdAt: '2024-01-05T00:00:00Z', + updatedAt: '2024-01-05T00:00:00Z', +}; + +describe('getCascadeSummary', () => { + it('describes dependent counts for makes', () => { + const modelsByMake = new Map([ + [baseMake.id, [baseModel]], + ]); + const yearsByModel = new Map([ + [baseModel.id, [baseYear]], + ]); + const trimsByYear = new Map([ + [baseYear.id, [baseTrim]], + ]); + const enginesByTrim = new Map([ + [baseTrim.id, [baseEngine]], + ]); + + const summary = getCascadeSummary( + 'makes', + [baseMake], + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim + ); + + expect(summary).toContain('1 model'); + expect(summary).toContain('1 year'); + expect(summary).toContain('1 trim'); + expect(summary).toContain('1 engine'); + }); + + it('returns empty string when nothing selected', () => { + const summary = getCascadeSummary( + 'models', + [], + new Map(), + new Map(), + new Map(), + new Map() + ); + expect(summary).toBe(''); + }); +}); + +describe('buildDefaultValues', () => { + it('prefills parent context for create operations', () => { + const context: CatalogSelectionContext = { + level: 'models', + make: baseMake, + }; + + const defaults = buildDefaultValues('models', 'create', undefined, context); + + expect(defaults.makeId).toBe(baseMake.id); + expect(defaults.name).toBe(''); + }); + + it('hydrates existing entity data for editing engines', () => { + const context: CatalogSelectionContext = { + level: 'engines', + make: baseMake, + model: baseModel, + year: baseYear, + trim: baseTrim, + }; + + const defaults = buildDefaultValues( + 'engines', + 'edit', + baseEngine, + context + ); + + expect(defaults.name).toBe(baseEngine.name); + expect(defaults.trimId).toBe(baseTrim.id); + expect(defaults.displacement).toBe('2.0L'); + expect(defaults.cylinders).toBe(4); + expect(defaults.fuel_type).toBe('Gasoline'); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/AdminSectionHeader.test.tsx b/frontend/src/features/admin/__tests__/components/AdminSectionHeader.test.tsx new file mode 100644 index 0000000..da254ca --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/AdminSectionHeader.test.tsx @@ -0,0 +1,43 @@ +/** + * @ai-summary Snapshot tests for AdminSectionHeader component + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { AdminSectionHeader } from '../../components/AdminSectionHeader'; + +describe('AdminSectionHeader', () => { + it('should render with title and stats', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render with empty stats', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('should format large numbers with locale', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/AdminSkeleton.test.tsx b/frontend/src/features/admin/__tests__/components/AdminSkeleton.test.tsx new file mode 100644 index 0000000..821ba82 --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/AdminSkeleton.test.tsx @@ -0,0 +1,39 @@ +/** + * @ai-summary Tests for AdminSkeleton components + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { AdminSkeleton } from '../../components/AdminSkeleton'; + +describe('AdminSkeleton', () => { + describe('SkeletonRow', () => { + it('should render default number of rows', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should render specified number of rows', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(15); // 3 skeletons per row * 5 rows + }); + }); + + describe('SkeletonCard', () => { + it('should render default number of cards', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should render specified number of cards', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(container.querySelectorAll('.MuiCard-root')).toHaveLength(4); + }); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/BulkActionDialog.test.tsx b/frontend/src/features/admin/__tests__/components/BulkActionDialog.test.tsx new file mode 100644 index 0000000..f8e0649 --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/BulkActionDialog.test.tsx @@ -0,0 +1,83 @@ +/** + * @ai-summary Tests for BulkActionDialog component + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BulkActionDialog } from '../../components/BulkActionDialog'; + +describe('BulkActionDialog', () => { + const defaultProps = { + open: true, + title: 'Delete Items?', + message: 'This action cannot be undone.', + items: ['Item 1', 'Item 2', 'Item 3'], + onConfirm: jest.fn(), + onCancel: jest.fn(), + }; + + it('should render dialog when open', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('Delete Items?')).toBeInTheDocument(); + expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument(); + }); + + it('should display list of items', () => { + render(); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Item 3')).toBeInTheDocument(); + }); + + it('should call onConfirm when confirm button clicked', () => { + const handleConfirm = jest.fn(); + + render(); + + fireEvent.click(screen.getByText('Confirm')); + expect(handleConfirm).toHaveBeenCalledTimes(1); + }); + + it('should call onCancel when cancel button clicked', () => { + const handleCancel = jest.fn(); + + render(); + + fireEvent.click(screen.getByText('Cancel')); + expect(handleCancel).toHaveBeenCalledTimes(1); + }); + + it('should disable buttons when loading', () => { + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + + expect(confirmButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + it('should show loading spinner when loading', () => { + const { container } = render( + + ); + + expect(container.querySelector('.MuiCircularProgress-root')).toBeInTheDocument(); + }); + + it('should support custom button text', () => { + render( + + ); + + expect(screen.getByText('Delete Now')).toBeInTheDocument(); + expect(screen.getByText('Go Back')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/EmptyState.test.tsx b/frontend/src/features/admin/__tests__/components/EmptyState.test.tsx new file mode 100644 index 0000000..37a010e --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/EmptyState.test.tsx @@ -0,0 +1,59 @@ +/** + * @ai-summary Tests for EmptyState component + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { EmptyState } from '../../components/EmptyState'; + +describe('EmptyState', () => { + it('should render with title and description', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('No Data')).toBeInTheDocument(); + expect(screen.getByText('Start by adding your first item')).toBeInTheDocument(); + }); + + it('should render with icon', () => { + const { container } = render( + Icon} + title="Empty" + description="No items found" + /> + ); + + expect(container).toMatchSnapshot(); + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('should render action button when provided', () => { + const handleAction = jest.fn(); + + render( + + ); + + const button = screen.getByText('Add Item'); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect(handleAction).toHaveBeenCalledTimes(1); + }); + + it('should not render action button when not provided', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/ErrorState.test.tsx b/frontend/src/features/admin/__tests__/components/ErrorState.test.tsx new file mode 100644 index 0000000..87a8a13 --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/ErrorState.test.tsx @@ -0,0 +1,46 @@ +/** + * @ai-summary Tests for ErrorState component + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorState } from '../../components/ErrorState'; + +describe('ErrorState', () => { + it('should render error message', () => { + const error = new Error('Failed to load data'); + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('Failed to load data')).toBeInTheDocument(); + }); + + it('should render retry button when onRetry provided', () => { + const handleRetry = jest.fn(); + const error = new Error('Network error'); + + render(); + + const retryButton = screen.getByText('Retry'); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(handleRetry).toHaveBeenCalledTimes(1); + }); + + it('should not render retry button when onRetry not provided', () => { + const error = new Error('Error occurred'); + + render(); + + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('should show default message when error has no message', () => { + const error = new Error(); + + render(); + + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/admin/__tests__/components/SelectionToolbar.test.tsx b/frontend/src/features/admin/__tests__/components/SelectionToolbar.test.tsx new file mode 100644 index 0000000..eba250c --- /dev/null +++ b/frontend/src/features/admin/__tests__/components/SelectionToolbar.test.tsx @@ -0,0 +1,63 @@ +/** + * @ai-summary Tests for SelectionToolbar component + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SelectionToolbar } from '../../components/SelectionToolbar'; + +describe('SelectionToolbar', () => { + it('should not render when selectedCount is 0', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render when items are selected', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('Selected: 3')).toBeInTheDocument(); + }); + + it('should call onClear when Clear button clicked', () => { + const handleClear = jest.fn(); + + render(); + + fireEvent.click(screen.getByText('Clear')); + expect(handleClear).toHaveBeenCalledTimes(1); + }); + + it('should call onSelectAll when Select All button clicked', () => { + const handleSelectAll = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Select All')); + expect(handleSelectAll).toHaveBeenCalledTimes(1); + }); + + it('should render custom action buttons', () => { + const { container } = render( + + + + + ); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Export')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/admin/__tests__/hooks/useBulkSelection.test.ts b/frontend/src/features/admin/__tests__/hooks/useBulkSelection.test.ts new file mode 100644 index 0000000..91095a7 --- /dev/null +++ b/frontend/src/features/admin/__tests__/hooks/useBulkSelection.test.ts @@ -0,0 +1,119 @@ +/** + * @ai-summary Tests for useBulkSelection hook + */ + +import { renderHook, act } from '@testing-library/react'; +import { useBulkSelection } from '../../hooks/useBulkSelection'; + +describe('useBulkSelection', () => { + const mockItems = [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + { id: '3', name: 'Item 3' }, + ]; + + it('should initialize with empty selection', () => { + const { result } = renderHook(() => + useBulkSelection({ items: mockItems }) + ); + + expect(result.current.count).toBe(0); + expect(result.current.selected.size).toBe(0); + }); + + it('should toggle individual item selection', () => { + const { result } = renderHook(() => + useBulkSelection({ items: mockItems }) + ); + + act(() => { + result.current.toggleItem('1'); + }); + + expect(result.current.count).toBe(1); + expect(result.current.isSelected('1')).toBe(true); + + act(() => { + result.current.toggleItem('1'); + }); + + expect(result.current.count).toBe(0); + expect(result.current.isSelected('1')).toBe(false); + }); + + it('should toggle all items', () => { + const { result } = renderHook(() => + useBulkSelection({ items: mockItems }) + ); + + act(() => { + result.current.toggleAll(mockItems); + }); + + expect(result.current.count).toBe(3); + expect(result.current.isSelected('1')).toBe(true); + expect(result.current.isSelected('2')).toBe(true); + expect(result.current.isSelected('3')).toBe(true); + + act(() => { + result.current.toggleAll(mockItems); + }); + + expect(result.current.count).toBe(0); + }); + + it('should reset all selections', () => { + const { result } = renderHook(() => + useBulkSelection({ items: mockItems }) + ); + + act(() => { + result.current.toggleItem('1'); + result.current.toggleItem('2'); + }); + + expect(result.current.count).toBe(2); + + act(() => { + result.current.reset(); + }); + + expect(result.current.count).toBe(0); + }); + + it('should return selected items', () => { + const { result } = renderHook(() => + useBulkSelection({ items: mockItems }) + ); + + act(() => { + result.current.toggleItem('1'); + result.current.toggleItem('3'); + }); + + expect(result.current.selectedItems).toHaveLength(2); + expect(result.current.selectedItems[0].id).toBe('1'); + expect(result.current.selectedItems[1].id).toBe('3'); + }); + + it('should support custom key extractor', () => { + const customItems = [ + { customId: 'a1', name: 'Item A' }, + { customId: 'a2', name: 'Item B' }, + ]; + + const { result } = renderHook(() => + useBulkSelection({ + items: customItems, + keyExtractor: (item) => item.customId, + }) + ); + + act(() => { + result.current.toggleItem('a1'); + }); + + expect(result.current.count).toBe(1); + expect(result.current.isSelected('a1')).toBe(true); + }); +}); diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index 6a0907c..f7c2864 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -63,8 +63,8 @@ export const adminApi = { // Catalog - Makes listMakes: async (): Promise => { - const response = await apiClient.get('/admin/catalog/makes'); - return response.data; + const response = await apiClient.get<{ makes: CatalogMake[] }>('/admin/catalog/makes'); + return response.data.makes; }, createMake: async (data: CreateCatalogMakeRequest): Promise => { @@ -82,10 +82,11 @@ export const adminApi = { }, // Catalog - Models - listModels: async (makeId?: string): Promise => { - const url = makeId ? `/admin/catalog/models?make_id=${makeId}` : '/admin/catalog/models'; - const response = await apiClient.get(url); - return response.data; + listModels: async (makeId: string): Promise => { + const response = await apiClient.get<{ models: CatalogModel[] }>( + `/admin/catalog/makes/${makeId}/models` + ); + return response.data.models; }, createModel: async (data: CreateCatalogModelRequest): Promise => { @@ -103,10 +104,11 @@ export const adminApi = { }, // Catalog - Years - listYears: async (modelId?: string): Promise => { - const url = modelId ? `/admin/catalog/years?model_id=${modelId}` : '/admin/catalog/years'; - const response = await apiClient.get(url); - return response.data; + listYears: async (modelId: string): Promise => { + const response = await apiClient.get<{ years: CatalogYear[] }>( + `/admin/catalog/models/${modelId}/years` + ); + return response.data.years; }, createYear: async (data: CreateCatalogYearRequest): Promise => { @@ -119,10 +121,11 @@ export const adminApi = { }, // Catalog - Trims - listTrims: async (yearId?: string): Promise => { - const url = yearId ? `/admin/catalog/trims?year_id=${yearId}` : '/admin/catalog/trims'; - const response = await apiClient.get(url); - return response.data; + listTrims: async (yearId: string): Promise => { + const response = await apiClient.get<{ trims: CatalogTrim[] }>( + `/admin/catalog/years/${yearId}/trims` + ); + return response.data.trims; }, createTrim: async (data: CreateCatalogTrimRequest): Promise => { @@ -140,10 +143,11 @@ export const adminApi = { }, // Catalog - Engines - listEngines: async (trimId?: string): Promise => { - const url = trimId ? `/admin/catalog/engines?trim_id=${trimId}` : '/admin/catalog/engines'; - const response = await apiClient.get(url); - return response.data; + listEngines: async (trimId: string): Promise => { + const response = await apiClient.get<{ engines: CatalogEngine[] }>( + `/admin/catalog/trims/${trimId}/engines` + ); + return response.data.engines; }, createEngine: async (data: CreateCatalogEngineRequest): Promise => { diff --git a/frontend/src/features/admin/catalog/catalogSchemas.ts b/frontend/src/features/admin/catalog/catalogSchemas.ts new file mode 100644 index 0000000..e308c76 --- /dev/null +++ b/frontend/src/features/admin/catalog/catalogSchemas.ts @@ -0,0 +1,151 @@ +import { z } from 'zod'; +import { + CatalogLevel, + CatalogRow, + CatalogSelectionContext, +} from './catalogShared'; +import { + CatalogMake, + CatalogModel, + CatalogYear, + CatalogTrim, + CatalogEngine, +} from '../types/admin.types'; + +export type CatalogFormValues = { + name?: string; + makeId?: string; + modelId?: string; + year?: number; + yearId?: string; + trimId?: string; + displacement?: string; + cylinders?: number; + fuel_type?: string; +}; + +export const makeSchema = z.object({ + name: z.string().min(1, 'Name is required'), +}); + +export const modelSchema = z.object({ + name: z.string().min(1, 'Name is required'), + makeId: z.string().min(1, 'Select a make'), +}); + +export const yearSchema = z.object({ + modelId: z.string().min(1, 'Select a model'), + year: z + .coerce.number() + .int() + .min(1900, 'Enter a valid year') + .max(2100, 'Enter a valid year'), +}); + +export const trimSchema = z.object({ + name: z.string().min(1, 'Name is required'), + yearId: z.string().min(1, 'Select a year'), +}); + +export const engineSchema = z.object({ + name: z.string().min(1, 'Name is required'), + trimId: z.string().min(1, 'Select a trim'), + displacement: z.string().optional(), + cylinders: z + .preprocess( + (value) => + value === '' || value === null || value === undefined + ? undefined + : Number(value), + z + .number() + .int() + .positive('Cylinders must be positive') + .optional() + ), + fuel_type: z.string().optional(), +}); + +export const getSchemaForLevel = (level: CatalogLevel) => { + switch (level) { + case 'makes': + return makeSchema; + case 'models': + return modelSchema; + case 'years': + return yearSchema; + case 'trims': + return trimSchema; + case 'engines': + return engineSchema; + default: + return makeSchema; + } +}; + +export const buildDefaultValues = ( + level: CatalogLevel, + mode: 'create' | 'edit', + entity: CatalogRow | undefined, + context: CatalogSelectionContext +): CatalogFormValues => { + if (mode === 'edit' && entity) { + switch (level) { + case 'makes': + return { name: (entity as CatalogMake).name }; + case 'models': + return { + name: (entity as CatalogModel).name, + makeId: (entity as CatalogModel).makeId, + }; + case 'years': + return { + modelId: (entity as CatalogYear).modelId, + year: (entity as CatalogYear).year, + }; + case 'trims': + return { + name: (entity as CatalogTrim).name, + yearId: (entity as CatalogTrim).yearId, + }; + case 'engines': + return { + name: (entity as CatalogEngine).name, + trimId: (entity as CatalogEngine).trimId, + displacement: (entity as CatalogEngine).displacement ?? undefined, + cylinders: (entity as CatalogEngine).cylinders ?? undefined, + fuel_type: (entity as CatalogEngine).fuel_type ?? undefined, + }; + default: + return {}; + } + } + + switch (level) { + case 'models': + return { + name: '', + makeId: context.make?.id ?? '', + }; + case 'years': + return { + modelId: context.model?.id ?? '', + year: undefined, + }; + case 'trims': + return { + name: '', + yearId: context.year?.id ?? '', + }; + case 'engines': + return { + name: '', + trimId: context.trim?.id ?? '', + displacement: '', + fuel_type: '', + }; + case 'makes': + default: + return { name: '' }; + } +}; diff --git a/frontend/src/features/admin/catalog/catalogShared.ts b/frontend/src/features/admin/catalog/catalogShared.ts new file mode 100644 index 0000000..158d9f5 --- /dev/null +++ b/frontend/src/features/admin/catalog/catalogShared.ts @@ -0,0 +1,157 @@ +import { + CatalogEngine, + CatalogMake, + CatalogModel, + CatalogTrim, + CatalogYear, +} from '../types/admin.types'; + +export type CatalogLevel = 'makes' | 'models' | 'years' | 'trims' | 'engines'; + +export type CatalogRow = + | CatalogMake + | CatalogModel + | CatalogYear + | CatalogTrim + | CatalogEngine; + +export interface CatalogSelectionContext { + level: CatalogLevel; + make?: CatalogMake; + model?: CatalogModel; + year?: CatalogYear; + trim?: CatalogTrim; +} + +export const LEVEL_LABEL: Record = { + makes: 'Makes', + models: 'Models', + years: 'Years', + trims: 'Trims', + engines: 'Engines', +}; + +export const LEVEL_SINGULAR_LABEL: Record = { + makes: 'Make', + models: 'Model', + years: 'Year', + trims: 'Trim', + engines: 'Engine', +}; + +export const NEXT_LEVEL: Record = { + makes: 'models', + models: 'years', + years: 'trims', + trims: 'engines', + engines: null, +}; + +export const pluralize = (count: number, singular: string): string => + `${count} ${singular}${count === 1 ? '' : 's'}`; + +export const getCascadeSummary = ( + level: CatalogLevel, + selectedItems: CatalogRow[], + modelsByMake: Map, + yearsByModel: Map, + trimsByYear: Map, + enginesByTrim: Map +): string => { + if (selectedItems.length === 0) { + return ''; + } + + if (level === 'engines') { + return 'Deleting engines will remove their configuration details.'; + } + + let modelCount = 0; + let yearCount = 0; + let trimCount = 0; + let engineCount = 0; + + if (level === 'makes') { + selectedItems.forEach((item) => { + const make = item as CatalogMake; + const makeModels = modelsByMake.get(make.id) ?? []; + modelCount += makeModels.length; + makeModels.forEach((model) => { + const modelYears = yearsByModel.get(model.id) ?? []; + yearCount += modelYears.length; + modelYears.forEach((year) => { + const yearTrims = trimsByYear.get(year.id) ?? []; + trimCount += yearTrims.length; + yearTrims.forEach((trim) => { + const trimEngines = enginesByTrim.get(trim.id) ?? []; + engineCount += trimEngines.length; + }); + }); + }); + }); + + return `Deleting ${selectedItems.length} ${LEVEL_LABEL.makes.toLowerCase()} will also remove ${pluralize( + modelCount, + 'model' + )}, ${pluralize(yearCount, 'year')}, ${pluralize( + trimCount, + 'trim' + )}, and ${pluralize(engineCount, 'engine')}.`; + } + + if (level === 'models') { + selectedItems.forEach((item) => { + const model = item as CatalogModel; + const modelYears = yearsByModel.get(model.id) ?? []; + yearCount += modelYears.length; + modelYears.forEach((year) => { + const yearTrims = trimsByYear.get(year.id) ?? []; + trimCount += yearTrims.length; + yearTrims.forEach((trim) => { + const trimEngines = enginesByTrim.get(trim.id) ?? []; + engineCount += trimEngines.length; + }); + }); + }); + + return `Deleting ${selectedItems.length} ${LEVEL_LABEL.models.toLowerCase()} will also remove ${pluralize( + yearCount, + 'year' + )}, ${pluralize(trimCount, 'trim')}, and ${pluralize( + engineCount, + 'engine' + )}.`; + } + + if (level === 'years') { + selectedItems.forEach((item) => { + const year = item as CatalogYear; + const yearTrims = trimsByYear.get(year.id) ?? []; + trimCount += yearTrims.length; + yearTrims.forEach((trim) => { + const trimEngines = enginesByTrim.get(trim.id) ?? []; + engineCount += trimEngines.length; + }); + }); + + return `Deleting ${selectedItems.length} ${LEVEL_LABEL.years.toLowerCase()} will also remove ${pluralize( + trimCount, + 'trim' + )} and ${pluralize(engineCount, 'engine')}.`; + } + + if (level === 'trims') { + selectedItems.forEach((item) => { + const trim = item as CatalogTrim; + const trimEngines = enginesByTrim.get(trim.id) ?? []; + engineCount += trimEngines.length; + }); + + return `Deleting ${selectedItems.length} ${LEVEL_LABEL.trims.toLowerCase()} will also remove ${pluralize( + engineCount, + 'engine' + )}.`; + } + + return ''; +}; diff --git a/frontend/src/features/admin/components/AdminDataGrid.tsx b/frontend/src/features/admin/components/AdminDataGrid.tsx new file mode 100644 index 0000000..fdf6d8a --- /dev/null +++ b/frontend/src/features/admin/components/AdminDataGrid.tsx @@ -0,0 +1,253 @@ +/** + * @ai-summary Reusable data grid component with selection and pagination + * @ai-context Table display with checkbox selection, sorting, and error/loading states + */ + +import React from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Checkbox, + Paper, + IconButton, + Typography, + TableSortLabel, +} from '@mui/material'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { EmptyState } from './EmptyState'; +import { ErrorState } from './ErrorState'; +import { AdminSkeleton } from './AdminSkeleton'; + +/** + * Column definition for data grid + */ +export interface GridColumn { + field: keyof T | string; + headerName: string; + sortable?: boolean; + width?: string | number; + renderCell?: (row: T) => React.ReactNode; +} + +/** + * Props for AdminDataGrid component + */ +export interface AdminDataGridProps { + rows: T[]; + columns: GridColumn[]; + selectedIds: Set; + onSelectChange: (id: string) => void; + onSelectAll?: () => void; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + toolbar?: React.ReactNode; + getRowId?: (row: T) => string; + emptyMessage?: string; + page?: number; + onPageChange?: (page: number) => void; + totalPages?: number; +} + +/** + * Data grid component with selection, sorting, and pagination + * + * @example + * ```tsx + * formatDate(row.createdAt) } + * ]} + * selectedIds={selected} + * onSelectChange={toggleItem} + * onSelectAll={toggleAll} + * /> + * ``` + */ +export function AdminDataGrid>({ + rows, + columns, + selectedIds, + onSelectChange, + onSelectAll, + loading = false, + error = null, + onRetry, + toolbar, + getRowId = (row) => row.id, + emptyMessage = 'No data available', + page = 0, + onPageChange, + totalPages = 1, +}: AdminDataGridProps): React.ReactElement { + // Loading state + if (loading) { + return ( + + {toolbar} + + + ); + } + + // Error state + if (error) { + return ( + + {toolbar} + + + ); + } + + // Empty state + if (rows.length === 0) { + return ( + + {toolbar} + + + ); + } + + const allSelected = + rows.length > 0 && rows.every((row) => selectedIds.has(getRowId(row))); + const someSelected = + rows.some((row) => selectedIds.has(getRowId(row))) && !allSelected; + + return ( + + {toolbar} + + + + + + {/* Checkbox column */} + + {onSelectAll && ( + + )} + + + {/* Data columns */} + {columns.map((column) => ( + + {column.sortable ? ( + + {column.headerName} + + ) : ( + column.headerName + )} + + ))} + + + + + {rows.map((row) => { + const rowId = getRowId(row); + const isSelected = selectedIds.has(rowId); + + return ( + + {/* Checkbox cell */} + + onSelectChange(rowId)} + inputProps={{ + 'aria-label': `select row ${rowId}`, + }} + sx={{ + minWidth: 44, + minHeight: 44, + }} + /> + + + {/* Data cells */} + {columns.map((column) => ( + + {column.renderCell + ? column.renderCell(row) + : row[column.field]} + + ))} + + ); + })} + +
+
+ + {/* Pagination controls */} + {onPageChange && totalPages > 1 && ( + + + Page {page + 1} of {totalPages} + + onPageChange(page - 1)} + disabled={page === 0} + aria-label="previous page" + sx={{ minWidth: 44, minHeight: 44 }} + > + + + onPageChange(page + 1)} + disabled={page >= totalPages - 1} + aria-label="next page" + sx={{ minWidth: 44, minHeight: 44 }} + > + + + + )} +
+ ); +} diff --git a/frontend/src/features/admin/components/AdminSectionHeader.tsx b/frontend/src/features/admin/components/AdminSectionHeader.tsx new file mode 100644 index 0000000..f8da387 --- /dev/null +++ b/frontend/src/features/admin/components/AdminSectionHeader.tsx @@ -0,0 +1,92 @@ +/** + * @ai-summary Header component for admin sections with title and stats + * @ai-context Displays section title with stat cards showing counts + */ + +import React from 'react'; +import { Box, Typography, Card, CardContent, Grid } from '@mui/material'; + +/** + * Stat item definition + */ +export interface StatItem { + label: string; + value: number; +} + +/** + * Props for AdminSectionHeader component + */ +export interface AdminSectionHeaderProps { + title: string; + stats: StatItem[]; +} + +/** + * Header component displaying title and stats cards + * + * @example + * ```tsx + * + * ``` + */ +export const AdminSectionHeader: React.FC = ({ + title, + stats, +}) => { + return ( + + + {title} + + + + {stats.map((stat) => ( + + + + + {stat.label} + + + {stat.value.toLocaleString()} + + + + + ))} + + + ); +}; diff --git a/frontend/src/features/admin/components/AdminSkeleton.tsx b/frontend/src/features/admin/components/AdminSkeleton.tsx new file mode 100644 index 0000000..36b1270 --- /dev/null +++ b/frontend/src/features/admin/components/AdminSkeleton.tsx @@ -0,0 +1,94 @@ +/** + * @ai-summary Skeleton loading components for admin views + * @ai-context Provides skeleton rows for tables and cards for mobile views + */ + +import React from 'react'; +import { Box, Skeleton, Card, CardContent } from '@mui/material'; + +/** + * Props for SkeletonRow component + */ +interface SkeletonRowProps { + count?: number; +} + +/** + * Skeleton loading rows for table views + * + * @example + * ```tsx + * + * ``` + */ +const SkeletonRow: React.FC = ({ count = 3 }) => { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + + + + + + + + ))} + + ); +}; + +/** + * Props for SkeletonCard component + */ +interface SkeletonCardProps { + count?: number; +} + +/** + * Skeleton loading cards for mobile views + * + * @example + * ```tsx + * + * ``` + */ +const SkeletonCard: React.FC = ({ count = 3 }) => { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + + + + + + + + + + + + + + + ))} + + ); +}; + +/** + * Admin skeleton loading components + */ +export const AdminSkeleton = { + SkeletonRow, + SkeletonCard, +}; diff --git a/frontend/src/features/admin/components/AuditLogDrawer.tsx b/frontend/src/features/admin/components/AuditLogDrawer.tsx new file mode 100644 index 0000000..5d4b45c --- /dev/null +++ b/frontend/src/features/admin/components/AuditLogDrawer.tsx @@ -0,0 +1,224 @@ +/** + * @ai-summary Mobile bottom sheet drawer for audit logs + * @ai-context Bottom drawer showing paginated audit logs optimized for mobile + */ + +import React from 'react'; +import { + Drawer, + Box, + Typography, + IconButton, + List, + ListItem, + ListItemText, + Divider, +} from '@mui/material'; +import { Close, ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { formatDistanceToNow } from 'date-fns'; +import { useAuditLogStream } from '../hooks/useAuditLogStream'; +import { AdminSkeleton } from './AdminSkeleton'; +import { EmptyState } from './EmptyState'; +import { ErrorState } from './ErrorState'; + +/** + * Props for AuditLogDrawer component + */ +export interface AuditLogDrawerProps { + open: boolean; + onClose: () => void; + resourceType?: 'admin' | 'catalog' | 'station'; + limit?: number; + pollIntervalMs?: number; +} + +/** + * Mobile bottom sheet drawer for displaying audit logs + * + * @example + * ```tsx + * setDrawerOpen(false)} + * resourceType="station" + * limit={50} + * /> + * ``` + */ +export const AuditLogDrawer: React.FC = ({ + open, + onClose, + resourceType, + limit = 50, + pollIntervalMs = 5000, +}) => { + const { + logs, + loading, + error, + pagination, + hasMore, + nextPage, + prevPage, + refetch, + lastUpdated, + } = useAuditLogStream({ + resourceType, + limit, + pollIntervalMs, + }); + + const resourceLabel = resourceType + ? `${resourceType.charAt(0).toUpperCase()}${resourceType.slice(1)} Changes` + : 'All Changes'; + + return ( + + + {/* Header */} + + + + Audit Logs + + + {resourceLabel} + + + + + + + + {/* Content */} + + {loading && } + + {error && } + + {!loading && !error && logs.length === 0 && ( + + )} + + {!loading && !error && logs.length > 0 && ( + + {logs.map((log) => ( + + + + {log.action} + + } + secondary={ + + + {formatDistanceToNow(new Date(log.createdAt), { + addSuffix: true, + })} + + {log.resourceType && ( + + | {log.resourceType} + + )} + + } + /> + + + + ))} + + )} + + + {/* Footer with pagination */} + {!loading && logs.length > 0 && ( + + + Updated{' '} + {formatDistanceToNow(lastUpdated, { + addSuffix: true, + })} + + + + + + + + + + + )} + + + ); +}; diff --git a/frontend/src/features/admin/components/AuditLogPanel.tsx b/frontend/src/features/admin/components/AuditLogPanel.tsx new file mode 100644 index 0000000..61bccef --- /dev/null +++ b/frontend/src/features/admin/components/AuditLogPanel.tsx @@ -0,0 +1,219 @@ +/** + * @ai-summary Desktop sidebar panel for audit logs + * @ai-context Collapsible panel showing paginated audit logs for desktop views + */ + +import React, { useState } from 'react'; +import { + Box, + Paper, + Typography, + IconButton, + List, + ListItem, + ListItemText, + Collapse, + Divider, +} from '@mui/material'; +import { + ExpandLess, + ExpandMore, + ChevronLeft, + ChevronRight, +} from '@mui/icons-material'; +import { formatDistanceToNow } from 'date-fns'; +import { useAuditLogStream } from '../hooks/useAuditLogStream'; +import { AdminSkeleton } from './AdminSkeleton'; +import { EmptyState } from './EmptyState'; +import { ErrorState } from './ErrorState'; + +/** + * Props for AuditLogPanel component + */ +export interface AuditLogPanelProps { + resourceType?: 'admin' | 'catalog' | 'station'; + limit?: number; + pollIntervalMs?: number; +} + +/** + * Desktop sidebar panel for displaying audit logs + * + * @example + * ```tsx + * + * ``` + */ +export const AuditLogPanel: React.FC = ({ + resourceType, + limit = 50, + pollIntervalMs = 5000, +}) => { + const [collapsed, setCollapsed] = useState(false); + + const { + logs, + loading, + error, + pagination, + hasMore, + nextPage, + prevPage, + refetch, + lastUpdated, + } = useAuditLogStream({ + resourceType, + limit, + pollIntervalMs, + }); + + const resourceLabel = resourceType + ? `${resourceType.charAt(0).toUpperCase()}${resourceType.slice(1)} Changes` + : 'All Changes'; + + return ( + + {/* Header */} + + + + Audit Logs + + + {resourceLabel} (Last {limit}) + + + setCollapsed(!collapsed)} + size="small" + aria-label={collapsed ? 'expand' : 'collapse'} + sx={{ minWidth: 44, minHeight: 44 }} + > + {collapsed ? : } + + + + {/* Content */} + + + {loading && } + + {error && } + + {!loading && !error && logs.length === 0 && ( + + )} + + {!loading && !error && logs.length > 0 && ( + + {logs.map((log) => ( + + + + {log.action} + + } + secondary={ + + + {formatDistanceToNow(new Date(log.createdAt), { + addSuffix: true, + })} + + {log.resourceType && ( + + | {log.resourceType} + + )} + + } + /> + + + + ))} + + )} + + + {/* Footer with pagination */} + {!loading && logs.length > 0 && ( + + + Updated{' '} + {formatDistanceToNow(lastUpdated, { + addSuffix: true, + })} + + + + + + + + + + + )} + + + ); +}; diff --git a/frontend/src/features/admin/components/BulkActionDialog.tsx b/frontend/src/features/admin/components/BulkActionDialog.tsx new file mode 100644 index 0000000..0d8b0c1 --- /dev/null +++ b/frontend/src/features/admin/components/BulkActionDialog.tsx @@ -0,0 +1,137 @@ +/** + * @ai-summary Confirmation dialog for bulk actions + * @ai-context Modal for confirming destructive bulk operations with item list + */ + +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + List, + ListItem, + ListItemText, + CircularProgress, + Box, +} from '@mui/material'; + +/** + * Props for BulkActionDialog component + */ +export interface BulkActionDialogProps { + open: boolean; + title: string; + message: string; + items: string[]; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; + confirmText?: string; + cancelText?: string; +} + +/** + * Confirmation dialog for bulk actions + * + * @example + * ```tsx + * + * ``` + */ +export const BulkActionDialog: React.FC = ({ + open, + title, + message, + items, + onConfirm, + onCancel, + loading = false, + confirmText = 'Confirm', + cancelText = 'Cancel', +}) => { + return ( + + {title} + + + + {message} + + + {items.length > 0 && ( + + + {items.map((item, index) => ( + + + + ))} + + + )} + + + + + + + + ); +}; diff --git a/frontend/src/features/admin/components/EmptyState.tsx b/frontend/src/features/admin/components/EmptyState.tsx new file mode 100644 index 0000000..d414e5b --- /dev/null +++ b/frontend/src/features/admin/components/EmptyState.tsx @@ -0,0 +1,106 @@ +/** + * @ai-summary Empty state component for when no data is available + * @ai-context Centered display with icon, title, description, and optional action + */ + +import React from 'react'; +import { Box, Typography, Button } from '@mui/material'; + +/** + * Action button configuration + */ +interface ActionConfig { + label: string; + onClick: () => void; +} + +/** + * Props for EmptyState component + */ +export interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description: string; + action?: ActionConfig; +} + +/** + * Empty state component for displaying when no data is available + * + * @example + * ```tsx + * } + * title="No Data" + * description="Start by adding your first item" + * action={{ label: 'Add Item', onClick: handleAdd }} + * /> + * ``` + */ +export const EmptyState: React.FC = ({ + icon, + title, + description, + action, +}) => { + return ( + + {icon && ( + + {icon} + + )} + + + {title} + + + + {description} + + + {action && ( + + )} + + ); +}; diff --git a/frontend/src/features/admin/components/ErrorState.tsx b/frontend/src/features/admin/components/ErrorState.tsx new file mode 100644 index 0000000..47eefab --- /dev/null +++ b/frontend/src/features/admin/components/ErrorState.tsx @@ -0,0 +1,73 @@ +/** + * @ai-summary Error state component with retry functionality + * @ai-context Centered error display with error message and retry button + */ + +import React from 'react'; +import { Box, Typography, Button, Alert } from '@mui/material'; +import { Refresh as RefreshIcon } from '@mui/icons-material'; + +/** + * Props for ErrorState component + */ +export interface ErrorStateProps { + error: Error; + onRetry?: () => void; +} + +/** + * Error state component for displaying errors with retry option + * + * @example + * ```tsx + * + * ``` + */ +export const ErrorState: React.FC = ({ error, onRetry }) => { + return ( + + + + An error occurred + + + {error.message || 'Something went wrong. Please try again.'} + + + + {onRetry && ( + + )} + + ); +}; diff --git a/frontend/src/features/admin/components/SelectionToolbar.tsx b/frontend/src/features/admin/components/SelectionToolbar.tsx new file mode 100644 index 0000000..dea8c15 --- /dev/null +++ b/frontend/src/features/admin/components/SelectionToolbar.tsx @@ -0,0 +1,105 @@ +/** + * @ai-summary Toolbar component for bulk selection actions + * @ai-context Displays selection count with select all, clear, and custom action buttons + */ + +import React from 'react'; +import { Box, Toolbar, Typography, Button } from '@mui/material'; + +/** + * Props for SelectionToolbar component + */ +export interface SelectionToolbarProps { + selectedCount: number; + onSelectAll?: () => void; + onClear: () => void; + children?: React.ReactNode; +} + +/** + * Toolbar component for displaying selection state and bulk actions + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export const SelectionToolbar: React.FC = ({ + selectedCount, + onSelectAll, + onClear, + children, +}) => { + // Only show toolbar if items are selected + if (selectedCount === 0) { + return null; + } + + return ( + + + Selected: {selectedCount} + + + + {onSelectAll && ( + + )} + + + + {children} + + + ); +}; diff --git a/frontend/src/features/admin/components/index.ts b/frontend/src/features/admin/components/index.ts new file mode 100644 index 0000000..fdb5746 --- /dev/null +++ b/frontend/src/features/admin/components/index.ts @@ -0,0 +1,30 @@ +/** + * @ai-summary Exports for all admin shared components + * @ai-context Central export point for Phase 1A components + */ + +export { AdminSectionHeader } from './AdminSectionHeader'; +export type { AdminSectionHeaderProps, StatItem } from './AdminSectionHeader'; + +export { AdminDataGrid } from './AdminDataGrid'; +export type { AdminDataGridProps, GridColumn } from './AdminDataGrid'; + +export { SelectionToolbar } from './SelectionToolbar'; +export type { SelectionToolbarProps } from './SelectionToolbar'; + +export { BulkActionDialog } from './BulkActionDialog'; +export type { BulkActionDialogProps } from './BulkActionDialog'; + +export { AuditLogPanel } from './AuditLogPanel'; +export type { AuditLogPanelProps } from './AuditLogPanel'; + +export { AuditLogDrawer } from './AuditLogDrawer'; +export type { AuditLogDrawerProps } from './AuditLogDrawer'; + +export { EmptyState } from './EmptyState'; +export type { EmptyStateProps } from './EmptyState'; + +export { ErrorState } from './ErrorState'; +export type { ErrorStateProps } from './ErrorState'; + +export { AdminSkeleton } from './AdminSkeleton'; diff --git a/frontend/src/features/admin/hooks/useAuditLogStream.ts b/frontend/src/features/admin/hooks/useAuditLogStream.ts new file mode 100644 index 0000000..cde4843 --- /dev/null +++ b/frontend/src/features/admin/hooks/useAuditLogStream.ts @@ -0,0 +1,140 @@ +/** + * @ai-summary Hook for streaming audit logs with polling + * @ai-context Polls audit logs every 5 seconds with pagination support + */ + +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAuth0 } from '@auth0/auth0-react'; +import { adminApi } from '../api/admin.api'; +import { AdminAuditLog } from '../types/admin.types'; + +/** + * Options for audit log stream + */ +interface AuditLogStreamOptions { + resourceType?: 'admin' | 'catalog' | 'station'; + limit?: number; + pollIntervalMs?: number; +} + +/** + * Pagination state + */ +interface PaginationState { + offset: number; + limit: number; + total: number; +} + +/** + * Return type for audit log stream hook + */ +interface UseAuditLogStreamReturn { + logs: AdminAuditLog[]; + loading: boolean; + error: any; + pagination: PaginationState; + hasMore: boolean; + nextPage: () => void; + prevPage: () => void; + refetch: () => void; + lastUpdated: Date; +} + +/** + * Custom hook for streaming audit logs with polling + * Uses polling until SSE backend is available + * + * @example + * ```typescript + * const { logs, loading, nextPage, prevPage } = useAuditLogStream({ + * resourceType: 'catalog', + * limit: 50, + * pollIntervalMs: 5000 + * }); + * ``` + */ +export function useAuditLogStream( + options: AuditLogStreamOptions = {} +): UseAuditLogStreamReturn { + const { resourceType, limit = 50, pollIntervalMs = 5000 } = options; + + const { isAuthenticated, isLoading: authLoading } = useAuth0(); + + const [offset, setOffset] = useState(0); + const [lastUpdated, setLastUpdated] = useState(new Date()); + + // Query for fetching audit logs + const { + data: rawLogs = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['auditLogs', resourceType, offset, limit], + queryFn: async () => { + const logs = await adminApi.listAuditLogs(); + setLastUpdated(new Date()); + return logs; + }, + enabled: isAuthenticated && !authLoading, + staleTime: pollIntervalMs, + gcTime: 10 * 60 * 1000, // 10 minutes + retry: 1, + refetchInterval: pollIntervalMs, + refetchOnWindowFocus: false, + }); + + // Filter logs by resource type if specified + const filteredLogs = resourceType + ? rawLogs.filter((log) => log.resourceType === resourceType) + : rawLogs; + + // Apply pagination + const paginatedLogs = filteredLogs.slice(offset, offset + limit); + + // Calculate pagination state + const pagination: PaginationState = { + offset, + limit, + total: filteredLogs.length, + }; + + const hasMore = offset + limit < filteredLogs.length; + + /** + * Navigate to next page + */ + const nextPage = useCallback(() => { + if (hasMore) { + setOffset((prev) => prev + limit); + } + }, [hasMore, limit]); + + /** + * Navigate to previous page + */ + const prevPage = useCallback(() => { + setOffset((prev) => Math.max(0, prev - limit)); + }, [limit]); + + /** + * Manual refetch wrapper + */ + const manualRefetch = useCallback(() => { + refetch(); + }, [refetch]); + + return { + logs: paginatedLogs, + loading: isLoading, + error, + pagination, + hasMore, + nextPage, + prevPage, + refetch: manualRefetch, + lastUpdated, + }; +} diff --git a/frontend/src/features/admin/hooks/useBulkSelection.ts b/frontend/src/features/admin/hooks/useBulkSelection.ts new file mode 100644 index 0000000..7a1d176 --- /dev/null +++ b/frontend/src/features/admin/hooks/useBulkSelection.ts @@ -0,0 +1,114 @@ +/** + * @ai-summary Hook for managing bulk selection state across paginated data + * @ai-context Supports individual toggle, select all, and reset operations + */ + +import { useState, useCallback, useMemo } from 'react'; + +/** + * Options for bulk selection hook + */ +interface UseBulkSelectionOptions { + items: T[]; + keyExtractor?: (item: T) => string; +} + +/** + * Return type for bulk selection hook + */ +interface UseBulkSelectionReturn { + selected: Set; + toggleItem: (id: string) => void; + toggleAll: (items: T[]) => void; + isSelected: (id: string) => boolean; + reset: () => void; + count: number; + selectedItems: T[]; +} + +/** + * Custom hook for managing bulk selection state + * Supports selection across pagination boundaries + * + * @example + * ```typescript + * const { selected, toggleItem, toggleAll, reset, count } = useBulkSelection({ items: data }); + * ``` + */ +export function useBulkSelection( + options: UseBulkSelectionOptions +): UseBulkSelectionReturn { + const { items, keyExtractor = (item: T) => item.id } = options; + + const [selected, setSelected] = useState>(new Set()); + + /** + * Toggle individual item selection + */ + const toggleItem = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + /** + * Toggle all items - if all are selected, deselect all; otherwise select all + * This supports "Select All" across pagination + */ + const toggleAll = useCallback((itemsToToggle: T[]) => { + setSelected((prev) => { + const itemIds = itemsToToggle.map(keyExtractor); + const allSelected = itemIds.every((id) => prev.has(id)); + + if (allSelected) { + // Deselect all items + const next = new Set(prev); + itemIds.forEach((id) => next.delete(id)); + return next; + } else { + // Select all items + const next = new Set(prev); + itemIds.forEach((id) => next.add(id)); + return next; + } + }); + }, [keyExtractor]); + + /** + * Check if item is selected + */ + const isSelected = useCallback( + (id: string) => selected.has(id), + [selected] + ); + + /** + * Clear all selections + */ + const reset = useCallback(() => { + setSelected(new Set()); + }, []); + + /** + * Get array of selected items from current items list + */ + const selectedItems = useMemo(() => { + return items.filter((item) => selected.has(keyExtractor(item))); + }, [items, selected, keyExtractor]); + + return { + selected, + toggleItem, + toggleAll, + isSelected, + reset, + count: selected.size, + selectedItems, + }; +} diff --git a/frontend/src/features/admin/hooks/useCatalog.ts b/frontend/src/features/admin/hooks/useCatalog.ts index fb11a59..b28af68 100644 --- a/frontend/src/features/admin/hooks/useCatalog.ts +++ b/frontend/src/features/admin/hooks/useCatalog.ts @@ -95,8 +95,8 @@ export const useModels = (makeId?: string) => { return useQuery({ queryKey: ['catalogModels', makeId], - queryFn: () => adminApi.listModels(makeId), - enabled: isAuthenticated && !isLoading, + queryFn: () => adminApi.listModels(makeId as string), + enabled: Boolean(makeId) && isAuthenticated && !isLoading, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: 1, @@ -156,8 +156,8 @@ export const useYears = (modelId?: string) => { return useQuery({ queryKey: ['catalogYears', modelId], - queryFn: () => adminApi.listYears(modelId), - enabled: isAuthenticated && !isLoading, + queryFn: () => adminApi.listYears(modelId as string), + enabled: Boolean(modelId) && isAuthenticated && !isLoading, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: 1, @@ -201,8 +201,8 @@ export const useTrims = (yearId?: string) => { return useQuery({ queryKey: ['catalogTrims', yearId], - queryFn: () => adminApi.listTrims(yearId), - enabled: isAuthenticated && !isLoading, + queryFn: () => adminApi.listTrims(yearId as string), + enabled: Boolean(yearId) && isAuthenticated && !isLoading, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: 1, @@ -262,8 +262,8 @@ export const useEngines = (trimId?: string) => { return useQuery({ queryKey: ['catalogEngines', trimId], - queryFn: () => adminApi.listEngines(trimId), - enabled: isAuthenticated && !isLoading, + queryFn: () => adminApi.listEngines(trimId as string), + enabled: Boolean(trimId) && isAuthenticated && !isLoading, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: 1, diff --git a/frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx index 95cc8e6..92faf35 100644 --- a/frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx @@ -1,24 +1,643 @@ -/** - * @ai-summary Mobile admin screen for vehicle catalog management - * @ai-context CRUD operations for makes, models, years, trims, engines with mobile UI - */ - -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Navigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { History } from '@mui/icons-material'; +import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; -import { useAdminAccess } from '../../../core/auth/useAdminAccess'; +import { AuditLogDrawer } from '../components/AuditLogDrawer'; +import { useBulkSelection } from '../hooks/useBulkSelection'; +import { + useMakes, + useCreateMake, + useUpdateMake, + useDeleteMake, + useModels, + useCreateModel, + useUpdateModel, + useDeleteModel, + useYears, + useCreateYear, + useDeleteYear, + useTrims, + useCreateTrim, + useUpdateTrim, + useDeleteTrim, + useEngines, + useCreateEngine, + useUpdateEngine, + useDeleteEngine, +} from '../hooks/useCatalog'; +import { + CatalogLevel, + CatalogRow, + CatalogSelectionContext, + LEVEL_LABEL, + LEVEL_SINGULAR_LABEL, + NEXT_LEVEL, + getCascadeSummary, +} from '../catalog/catalogShared'; +import { + CatalogFormValues, + buildDefaultValues, +} from '../catalog/catalogSchemas'; +import { + CatalogEngine, + CatalogMake, + CatalogModel, + CatalogTrim, + CatalogYear, +} from '../types/admin.types'; + +interface BreadcrumbItem { + label: string; + context: CatalogSelectionContext; +} + +const getCardChildSummary = ( + level: CatalogLevel, + item: CatalogRow, + modelsByMake: Map, + yearsByModel: Map, + trimsByYear: Map, + enginesByTrim: Map +): string | null => { + switch (level) { + case 'makes': { + const count = modelsByMake.get(item.id)?.length ?? 0; + return `${count} ${count === 1 ? 'model' : 'models'}`; + } + case 'models': { + const count = yearsByModel.get(item.id)?.length ?? 0; + return `${count} ${count === 1 ? 'year' : 'years'}`; + } + case 'years': { + const count = trimsByYear.get(item.id)?.length ?? 0; + return `${count} ${count === 1 ? 'trim' : 'trims'}`; + } + case 'trims': { + const count = enginesByTrim.get(item.id)?.length ?? 0; + return `${count} ${count === 1 ? 'engine' : 'engines'}`; + } + default: + return null; + } +}; + +const getRowDisplayName = (level: CatalogLevel, row: CatalogRow): string => { + if (level === 'years') { + return String((row as CatalogYear).year); + } + return ( + row as CatalogMake | CatalogModel | CatalogTrim | CatalogEngine + ).name; +}; + +const normalizeCollection = (value: unknown, key: string): T[] => { + if (Array.isArray(value)) { + return value as T[]; + } + if ( + value && + typeof value === 'object' && + Array.isArray((value as Record)[key]) + ) { + return (value as Record)[key]; + } + return []; +}; export const AdminCatalogMobileScreen: React.FC = () => { - const { isAdmin, loading } = useAdminAccess(); + const { isAdmin, loading: authLoading } = useAdminAccess(); + const queryClient = useQueryClient(); - if (loading) { + const [selection, setSelection] = useState({ + level: 'makes', + }); + const [multiSelectMode, setMultiSelectMode] = useState(false); + const [bulkSheetOpen, setBulkSheetOpen] = useState(false); + const [auditDrawerOpen, setAuditDrawerOpen] = useState(false); + const [formState, setFormState] = useState<{ + open: boolean; + mode: 'create' | 'edit'; + entity?: CatalogRow; + data: CatalogFormValues; + }>({ + open: false, + mode: 'create', + entity: undefined, + data: {}, + }); + + const makesQuery = useMakes(); + const modelsQuery = useModels(selection.make?.id); + const yearsQuery = useYears(selection.model?.id); + const trimsQuery = useTrims(selection.year?.id); + const enginesQuery = useEngines(selection.trim?.id); + + const makes = normalizeCollection(makesQuery.data, 'makes'); + const models = selection.make + ? normalizeCollection(modelsQuery.data, 'models') + : []; + const years = selection.model + ? normalizeCollection(yearsQuery.data, 'years') + : []; + const trims = selection.year + ? normalizeCollection(trimsQuery.data, 'trims') + : []; + const engines = selection.trim + ? normalizeCollection(enginesQuery.data, 'engines') + : []; + + const createMake = useCreateMake(); + const updateMake = useUpdateMake(); + const deleteMake = useDeleteMake(); + + const createModel = useCreateModel(); + const updateModel = useUpdateModel(); + const deleteModel = useDeleteModel(); + + const createYear = useCreateYear(); + const deleteYear = useDeleteYear(); + + const createTrim = useCreateTrim(); + const updateTrim = useUpdateTrim(); + const deleteTrim = useDeleteTrim(); + + const createEngine = useCreateEngine(); + const updateEngine = useUpdateEngine(); + const deleteEngine = useDeleteEngine(); + + const modelsByMake = useMemo(() => { + const map = new Map(); + models.forEach((model) => { + const list = map.get(model.makeId) ?? []; + list.push(model); + map.set(model.makeId, list); + }); + return map; + }, [models]); + + const yearsByModel = useMemo(() => { + const map = new Map(); + years.forEach((year) => { + const list = map.get(year.modelId) ?? []; + list.push(year); + map.set(year.modelId, list); + }); + return map; + }, [years]); + + const trimsByYear = useMemo(() => { + const map = new Map(); + trims.forEach((trim) => { + const list = map.get(trim.yearId) ?? []; + list.push(trim); + map.set(trim.yearId, list); + }); + return map; + }, [trims]); + + const enginesByTrim = useMemo(() => { + const map = new Map(); + engines.forEach((engine) => { + const list = map.get(engine.trimId) ?? []; + list.push(engine); + map.set(engine.trimId, list); + }); + return map; + }, [engines]); + + const currentData = useMemo(() => { + switch (selection.level) { + case 'makes': + return makes; + case 'models': + return selection.make ? models : []; + case 'years': + return selection.model ? years : []; + case 'trims': + return selection.year ? trims : []; + case 'engines': + return selection.trim ? engines : []; + default: + return []; + } + }, [selection, makes, models, years, trims, engines]); + + const { + selected, + toggleItem, + reset: resetSelection, + count: selectedCount, + selectedItems, + } = useBulkSelection({ + items: currentData, + keyExtractor: (item) => item.id, + }); + + const cascadeSummary = useMemo( + () => + getCascadeSummary( + selection.level, + selectedItems, + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim + ), + [ + selection.level, + selectedItems, + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim, + ] + ); + + const breadcrumbs = useMemo(() => { + const crumbs: BreadcrumbItem[] = [ + { label: 'Catalog', context: { level: 'makes' } }, + ]; + + if (selection.make) { + crumbs.push({ label: 'Makes', context: { level: 'makes' } }); + crumbs.push({ + label: selection.make.name, + context: { + level: 'models', + make: selection.make, + }, + }); + } + + if (selection.model) { + crumbs.push({ + label: 'Models', + context: { + level: 'models', + make: selection.make, + }, + }); + crumbs.push({ + label: selection.model.name, + context: { + level: 'years', + make: selection.make, + model: selection.model, + }, + }); + } + + if (selection.year) { + crumbs.push({ + label: 'Years', + context: { + level: 'years', + make: selection.make, + model: selection.model, + year: selection.year, + }, + }); + crumbs.push({ + label: String(selection.year.year), + context: { + level: 'trims', + make: selection.make, + model: selection.model, + year: selection.year, + }, + }); + } + + if (selection.trim) { + crumbs.push({ + label: 'Trims', + context: { + level: 'trims', + make: selection.make, + model: selection.model, + year: selection.year, + trim: selection.trim, + }, + }); + crumbs.push({ + label: selection.trim.name, + context: { + level: 'engines', + make: selection.make, + model: selection.model, + year: selection.year, + trim: selection.trim, + }, + }); + } + + const currentLabel = LEVEL_LABEL[selection.level]; + if (!crumbs.some((crumb) => crumb.label === currentLabel)) { + crumbs.push({ label: currentLabel, context: selection }); + } + + return crumbs; + }, [selection]); + + const isLoading = + makesQuery.isLoading || + modelsQuery.isLoading || + yearsQuery.isLoading || + trimsQuery.isLoading || + enginesQuery.isLoading; + + const openCreateForm = () => { + setFormState({ + open: true, + mode: 'create', + entity: undefined, + data: buildDefaultValues(selection.level, 'create', undefined, selection), + }); + }; + + const openEditForm = (item: CatalogRow) => { + setFormState({ + open: true, + mode: 'edit', + entity: item, + data: buildDefaultValues(selection.level, 'edit', item, selection), + }); + }; + + const closeForm = () => { + setFormState({ + open: false, + mode: 'create', + entity: undefined, + data: {}, + }); + }; + + const updateFormField = (field: keyof CatalogFormValues, value: string | number | undefined) => { + setFormState((prev) => ({ + ...prev, + data: { + ...prev.data, + [field]: value, + }, + })); + }; + + const invalidateCatalogQueries = () => { + queryClient.invalidateQueries({ queryKey: ['catalogMakes'] }); + queryClient.invalidateQueries({ queryKey: ['catalogModels'] }); + queryClient.invalidateQueries({ queryKey: ['catalogYears'] }); + queryClient.invalidateQueries({ queryKey: ['catalogTrims'] }); + queryClient.invalidateQueries({ queryKey: ['catalogEngines'] }); + }; + + const handleFormSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const data = formState.data; + + try { + if (formState.mode === 'create') { + switch (selection.level) { + case 'makes': + await createMake.mutateAsync({ + name: data.name?.trim() ?? '', + }); + break; + case 'models': + if (!selection.make) { + toast.error('Select a make before adding models'); + return; + } + await createModel.mutateAsync({ + name: data.name?.trim() ?? '', + makeId: selection.make.id, + }); + break; + case 'years': + if (!selection.model) { + toast.error('Select a model before adding years'); + return; + } + await createYear.mutateAsync({ + modelId: selection.model.id, + year: Number(data.year), + }); + break; + case 'trims': + if (!selection.year) { + toast.error('Select a year before adding trims'); + return; + } + await createTrim.mutateAsync({ + name: data.name?.trim() ?? '', + yearId: selection.year.id, + }); + break; + case 'engines': + if (!selection.trim) { + toast.error('Select a trim before adding engines'); + return; + } + await createEngine.mutateAsync({ + name: data.name?.trim() ?? '', + trimId: selection.trim.id, + displacement: data.displacement, + cylinders: + data.cylinders === undefined || data.cylinders === null + ? undefined + : Number(data.cylinders), + fuel_type: data.fuel_type, + }); + break; + } + } else if (formState.entity) { + const entityId = formState.entity.id; + switch (selection.level) { + case 'makes': + await updateMake.mutateAsync({ + id: entityId, + data: { name: data.name?.trim() ?? '' }, + }); + break; + case 'models': + await updateModel.mutateAsync({ + id: entityId, + data: { name: data.name?.trim() ?? '' }, + }); + break; + case 'trims': + await updateTrim.mutateAsync({ + id: entityId, + data: { name: data.name?.trim() ?? '' }, + }); + break; + case 'engines': + await updateEngine.mutateAsync({ + id: entityId, + data: { + name: data.name?.trim(), + displacement: data.displacement, + cylinders: + data.cylinders === undefined || data.cylinders === null + ? undefined + : Number(data.cylinders), + fuel_type: data.fuel_type, + }, + }); + break; + default: + break; + } + } + + closeForm(); + resetSelection(); + } catch { + // Errors surfaced through toast in mutation hooks. + } + }; + + const handleDeleteSingle = async (id: string) => { + const confirmed = window.confirm( + `Delete this ${LEVEL_SINGULAR_LABEL[selection.level]}?` + ); + if (!confirmed) return; + + try { + switch (selection.level) { + case 'makes': + await deleteMake.mutateAsync(id); + break; + case 'models': + await deleteModel.mutateAsync(id); + break; + case 'years': + await deleteYear.mutateAsync(id); + break; + case 'trims': + await deleteTrim.mutateAsync(id); + break; + case 'engines': + await deleteEngine.mutateAsync(id); + break; + default: + break; + } + invalidateCatalogQueries(); + resetSelection(); + } catch { + // Mutation hooks notify user. + } + }; + + const handleBulkDelete = async () => { + if (selectedCount === 0) return; + setBulkSheetOpen(false); + + const ids = Array.from(selected); + let deleted = 0; + let failed = 0; + + for (const id of ids) { + try { + switch (selection.level) { + case 'makes': + await deleteMake.mutateAsync(id); + break; + case 'models': + await deleteModel.mutateAsync(id); + break; + case 'years': + await deleteYear.mutateAsync(id); + break; + case 'trims': + await deleteTrim.mutateAsync(id); + break; + case 'engines': + await deleteEngine.mutateAsync(id); + break; + default: + break; + } + deleted += 1; + } catch { + failed += 1; + } + } + + invalidateCatalogQueries(); + setMultiSelectMode(false); + resetSelection(); + + if (failed > 0) { + toast.error( + `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}, failed ${failed}` + ); + } else { + toast.success( + `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}` + ); + } + }; + + const drillDown = (item: CatalogRow) => { + if (multiSelectMode) return; + + switch (selection.level) { + case 'makes': + setSelection({ level: 'models', make: item as CatalogMake }); + resetSelection(); + break; + case 'models': + setSelection({ + level: 'years', + make: selection.make, + model: item as CatalogModel, + }); + resetSelection(); + break; + case 'years': + setSelection({ + level: 'trims', + make: selection.make, + model: selection.model, + year: item as CatalogYear, + }); + resetSelection(); + break; + case 'trims': + setSelection({ + level: 'engines', + make: selection.make, + model: selection.model, + year: selection.year, + trim: item as CatalogTrim, + }); + resetSelection(); + break; + default: + break; + } + }; + + const handleBreadcrumbClick = (crumb: BreadcrumbItem, index: number) => { + if (index === breadcrumbs.length - 1) return; + setSelection(crumb.context); + setMultiSelectMode(false); + resetSelection(); + }; + + if (authLoading) { return (
-
Loading admin access...
-
+
Loading admin access…
+
@@ -29,33 +648,377 @@ export const AdminCatalogMobileScreen: React.FC = () => { return ; } + const currentLevelLabel = LEVEL_LABEL[selection.level]; + const nextLevel = NEXT_LEVEL[selection.level]; + return ( -
-
-

Vehicle Catalog

-

Manage platform vehicle data

-
- - -
-

Platform Catalog

-

- Vehicle catalog management interface coming soon. -

-
-

Features:

-
    -
  • Manage vehicle makes
  • -
  • Manage vehicle models
  • -
  • Manage model years
  • -
  • Manage trims
  • -
  • Manage engine specifications
  • -
+
+
+
+
+

Vehicle Catalog

+

{currentLevelLabel}

+
+
+ +
- + +
+ {breadcrumbs.map((crumb, index) => ( + + {index > 0 && /} + + + ))} +
+ +
+

+ {currentData.length} {currentLevelLabel.toLowerCase()} +

+ {currentData.length > 0 && ( + + )} +
+
+ + {isLoading && ( +
+
+
+ )} + + {!isLoading && currentData.length === 0 && ( + +
+

No {currentLevelLabel.toLowerCase()} found

+ +
+
+ )} + + {!isLoading && currentData.length > 0 && ( +
+ {currentData.map((item) => { + const isSelected = selected.has(item.id); + const displayName = getRowDisplayName(selection.level, item); + const childSummary = getCardChildSummary( + selection.level, + item, + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim + ); + + return ( + { + if (multiSelectMode) { + toggleItem(item.id); + } else { + drillDown(item); + } + }} + > +
+ {multiSelectMode && ( + toggleItem(item.id)} + className="mt-1" + style={{ minWidth: '20px', minHeight: '20px' }} + /> + )} +
+

+ {displayName} +

+
+ + Created: {new Date(item.createdAt).toLocaleDateString()} + + | + + Updated: {new Date(item.updatedAt).toLocaleDateString()} + +
+ {childSummary && ( +

{childSummary}

+ )} + + {!multiSelectMode && ( +
+ {selection.level !== 'years' && selection.level !== 'engines' && ( + + )} + {selection.level === 'engines' && ( + + )} + + {nextLevel && ( + + )} +
+ )} +
+
+
+ ); + })} +
+ )}
+ + {multiSelectMode && selectedCount > 0 && ( +
+
+ Selected: {selectedCount} +
+ + +
+
+
+ )} + + {formState.open && ( +
+
+

+ {formState.mode === 'edit' + ? `Edit ${LEVEL_SINGULAR_LABEL[selection.level]}` + : `Create ${LEVEL_SINGULAR_LABEL[selection.level]}`} +

+
+ {selection.level === 'years' ? ( + + updateFormField( + 'year', + e.target.value === '' ? undefined : Number(e.target.value) + ) + } + className="w-full border border-slate-300 rounded-lg px-4 py-3 text-slate-800" + style={{ minHeight: '44px' }} + required + /> + ) : ( + updateFormField('name', e.target.value)} + className="w-full border border-slate-300 rounded-lg px-4 py-3 text-slate-800" + style={{ minHeight: '44px' }} + required + /> + )} + + {selection.level === 'engines' && ( + <> + updateFormField('displacement', e.target.value)} + className="w-full border border-slate-300 rounded-lg px-4 py-3 text-slate-800" + style={{ minHeight: '44px' }} + /> + + updateFormField( + 'cylinders', + e.target.value === '' ? undefined : Number(e.target.value) + ) + } + className="w-full border border-slate-300 rounded-lg px-4 py-3 text-slate-800" + style={{ minHeight: '44px' }} + /> + updateFormField('fuel_type', e.target.value)} + className="w-full border border-slate-300 rounded-lg px-4 py-3 text-slate-800" + style={{ minHeight: '44px' }} + /> + + )} + +
+ + +
+
+
+
+ )} + + {bulkSheetOpen && ( +
+
+

+ Delete {selectedCount} {currentLevelLabel.toLowerCase()}? +

+ {cascadeSummary ? ( +

{cascadeSummary}

+ ) : ( +

+ This action cannot be undone. Dependent items will also be deleted. +

+ )} +
+
    + {selectedItems.map((item) => ( +
  • + {getRowDisplayName(selection.level, item)} +
  • + ))} +
+
+
+ + +
+
+
+ )} + + setAuditDrawerOpen(false)} + resourceType="catalog" + /> ); }; diff --git a/frontend/src/pages/admin/AdminCatalogPage.tsx b/frontend/src/pages/admin/AdminCatalogPage.tsx index e4fab9c..ca9d0d6 100644 --- a/frontend/src/pages/admin/AdminCatalogPage.tsx +++ b/frontend/src/pages/admin/AdminCatalogPage.tsx @@ -1,20 +1,1119 @@ -/** - * @ai-summary Desktop admin page for vehicle catalog management - * @ai-context CRUD operations for makes, models, years, trims, engines - */ - -import React from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { Navigate } from 'react-router-dom'; -import { Box, Typography, CircularProgress } from '@mui/material'; -import { Card } from '../../shared-minimal/components/Card'; +import { + Box, + Breadcrumbs, + Button, + CircularProgress, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Link, + List, + ListItemButton, + ListItemText, + MenuItem, + Paper, + TextField, + Typography, +} from '@mui/material'; +import { + Add, + ChevronRight, + Delete, + Edit, + ExpandLess, + ExpandMore, +} from '@mui/icons-material'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; import { useAdminAccess } from '../../core/auth/useAdminAccess'; +import { + AdminDataGrid, + GridColumn, + AdminSectionHeader, + SelectionToolbar, + BulkActionDialog, + AuditLogPanel, +} from '../../features/admin/components'; +import { useBulkSelection } from '../../features/admin/hooks/useBulkSelection'; +import { + useMakes, + useCreateMake, + useUpdateMake, + useDeleteMake, + useModels, + useCreateModel, + useUpdateModel, + useDeleteModel, + useYears, + useCreateYear, + useDeleteYear, + useTrims, + useCreateTrim, + useUpdateTrim, + useDeleteTrim, + useEngines, + useCreateEngine, + useUpdateEngine, + useDeleteEngine, +} from '../../features/admin/hooks/useCatalog'; +import { + CatalogLevel, + CatalogRow, + CatalogSelectionContext, + LEVEL_LABEL, + LEVEL_SINGULAR_LABEL, + NEXT_LEVEL, + getCascadeSummary, +} from '../../features/admin/catalog/catalogShared'; +import { + CatalogFormValues, + buildDefaultValues, + getSchemaForLevel, +} from '../../features/admin/catalog/catalogSchemas'; +import { + CatalogEngine, + CatalogMake, + CatalogModel, + CatalogTrim, + CatalogYear, +} from '../../features/admin/types/admin.types'; + +interface TreeNode { + id: string; + label: string; + level?: CatalogLevel; + children?: TreeNode[]; +} + +interface BreadcrumbItem { + key: string; + label: string; + target: CatalogSelectionContext; +} + +interface DialogState { + open: boolean; + mode: 'create' | 'edit'; + level: CatalogLevel; + entity?: CatalogRow; + context: CatalogSelectionContext; +} + +const normalizeCollection = (value: unknown, key: string): T[] => { + if (Array.isArray(value)) { + return value as T[]; + } + if ( + value && + typeof value === 'object' && + Array.isArray((value as Record)[key]) + ) { + return (value as Record)[key]; + } + return []; +}; export const AdminCatalogPage: React.FC = () => { - const { isAdmin, loading } = useAdminAccess(); + const { isAdmin, loading: authLoading } = useAdminAccess(); + const queryClient = useQueryClient(); - if (loading) { + const [selection, setSelection] = useState({ + level: 'makes', + }); + + const makesQuery = useMakes(); + const modelsQuery = useModels(selection.make?.id); + const yearsQuery = useYears(selection.model?.id); + const trimsQuery = useTrims(selection.year?.id); + const enginesQuery = useEngines(selection.trim?.id); + + const makes = normalizeCollection(makesQuery.data, 'makes'); + const models = + selection.make !== undefined + ? normalizeCollection(modelsQuery.data, 'models') + : []; + const years = + selection.model !== undefined + ? normalizeCollection(yearsQuery.data, 'years') + : []; + const trims = + selection.year !== undefined + ? normalizeCollection(trimsQuery.data, 'trims') + : []; + const engines = + selection.trim !== undefined + ? normalizeCollection(enginesQuery.data, 'engines') + : []; + + const createMake = useCreateMake(); + const updateMake = useUpdateMake(); + const deleteMake = useDeleteMake(); + + const createModel = useCreateModel(); + const updateModel = useUpdateModel(); + const deleteModel = useDeleteModel(); + + const createYear = useCreateYear(); + const deleteYear = useDeleteYear(); + + const createTrim = useCreateTrim(); + const updateTrim = useUpdateTrim(); + const deleteTrim = useDeleteTrim(); + + const createEngine = useCreateEngine(); + const updateEngine = useUpdateEngine(); + const deleteEngine = useDeleteEngine(); + const [expandedNodes, setExpandedNodes] = useState>( + () => new Set(['node-makes', 'node-models', 'node-years', 'node-trims']) + ); + const [dialogState, setDialogState] = useState(null); + const [bulkDialogOpen, setBulkDialogOpen] = useState(false); + const [bulkDeleting, setBulkDeleting] = useState(false); + + const makesById = useMemo(() => { + const map = new Map(); + makes.forEach((make) => map.set(make.id, make)); + return map; + }, [makes]); + + const modelsById = useMemo(() => { + const map = new Map(); + models.forEach((model) => map.set(model.id, model)); + return map; + }, [models]); + + const yearsById = useMemo(() => { + const map = new Map(); + years.forEach((year) => map.set(year.id, year)); + return map; + }, [years]); + + const trimsById = useMemo(() => { + const map = new Map(); + trims.forEach((trim) => map.set(trim.id, trim)); + return map; + }, [trims]); + + const modelsByMake = useMemo(() => { + const map = new Map(); + models.forEach((model) => { + const list = map.get(model.makeId) ?? []; + list.push(model); + map.set(model.makeId, list); + }); + return map; + }, [models]); + + const yearsByModel = useMemo(() => { + const map = new Map(); + years.forEach((year) => { + const list = map.get(year.modelId) ?? []; + list.push(year); + map.set(year.modelId, list); + }); + return map; + }, [years]); + + const trimsByYear = useMemo(() => { + const map = new Map(); + trims.forEach((trim) => { + const list = map.get(trim.yearId) ?? []; + list.push(trim); + map.set(trim.yearId, list); + }); + return map; + }, [trims]); + + const enginesByTrim = useMemo(() => { + const map = new Map(); + engines.forEach((engine) => { + const list = map.get(engine.trimId) ?? []; + list.push(engine); + map.set(engine.trimId, list); + }); + return map; + }, [engines]); + + const levelRequirements = useMemo< + Record + >(() => ({ + makes: { valid: true }, + models: selection.make + ? { valid: true } + : { valid: false, message: 'Select a make to view models.' }, + years: selection.model + ? { valid: true } + : { valid: false, message: 'Select a model to view years.' }, + trims: selection.year + ? { valid: true } + : { valid: false, message: 'Select a year to view trims.' }, + engines: selection.trim + ? { valid: true } + : { valid: false, message: 'Select a trim to view engines.' }, + }), [selection]); + + const currentLevelRequirement = levelRequirements[selection.level]; + const canViewLevel = currentLevelRequirement.valid; + + const currentRows = useMemo(() => { + if (!canViewLevel) { + return []; + } + + switch (selection.level) { + case 'makes': + return makes; + case 'models': + return models; + case 'years': + return years; + case 'trims': + return trims; + case 'engines': + return engines; + default: + return makes; + } + }, [canViewLevel, selection.level, makes, models, years, trims, engines]); + + const { + selected, + toggleItem, + toggleAll, + reset: resetBulkSelection, + count: selectedCount, + selectedItems, + } = useBulkSelection({ + items: currentRows, + keyExtractor: (item) => item.id, + }); + + useEffect(() => { + resetBulkSelection(); + }, [ + resetBulkSelection, + selection.level, + selection.make?.id, + selection.model?.id, + selection.year?.id, + selection.trim?.id, + ]); + + const queryByLevel = useMemo( + () => ({ + makes: makesQuery, + models: modelsQuery, + years: yearsQuery, + trims: trimsQuery, + engines: enginesQuery, + }), + [makesQuery, modelsQuery, yearsQuery, trimsQuery, enginesQuery] + ); + + const activeQuery = queryByLevel[selection.level]; + const activeError = activeQuery.error + ? activeQuery.error instanceof Error + ? activeQuery.error + : new Error('Failed to load data') + : null; + + const toggleTreeNode = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + const handleLevelSelect = useCallback((level: CatalogLevel) => { + setSelection((prev) => { + switch (level) { + case 'makes': + return { level: 'makes' }; + case 'models': + if (!prev.make) { + toast.error('Select a make to view models.'); + return prev; + } + return { + level: 'models', + make: prev.make, + }; + case 'years': + if (!prev.model) { + toast.error('Select a model to view years.'); + return prev; + } + return { + level: 'years', + make: prev.make, + model: prev.model, + }; + case 'trims': + if (!prev.year) { + toast.error('Select a year to view trims.'); + return prev; + } + return { + level: 'trims', + make: prev.make, + model: prev.model, + year: prev.year, + }; + case 'engines': + default: + if (!prev.trim) { + toast.error('Select a trim to view engines.'); + return prev; + } + return { + level: 'engines', + make: prev.make, + model: prev.model, + year: prev.year, + trim: prev.trim, + }; + } + }); + }, []); + + const deriveContextFromRow = useCallback( + (row: CatalogRow, level: CatalogLevel): CatalogSelectionContext => { + switch (level) { + case 'makes': + return { level, make: row as CatalogMake }; + case 'models': { + const model = row as CatalogModel; + const make = makesById.get(model.makeId); + return { + level, + make, + model, + }; + } + case 'years': { + const year = row as CatalogYear; + const model = modelsById.get(year.modelId); + const make = model ? makesById.get(model.makeId) : undefined; + return { + level, + make, + model, + year, + }; + } + case 'trims': { + const trim = row as CatalogTrim; + const year = yearsById.get(trim.yearId); + const model = year ? modelsById.get(year.modelId) : undefined; + const make = model ? makesById.get(model.makeId) : undefined; + return { + level, + make, + model, + year, + trim, + }; + } + case 'engines': { + const engine = row as CatalogEngine; + const trim = trimsById.get(engine.trimId); + const year = trim ? yearsById.get(trim.yearId) : undefined; + const model = year ? modelsById.get(year.modelId) : undefined; + const make = model ? makesById.get(model.makeId) : undefined; + return { + level, + make, + model, + year, + trim, + }; + } + default: + return { level }; + } + }, + [makesById, modelsById, yearsById, trimsById] + ); + + const handleDrillDown = useCallback( + (row: CatalogRow) => { + const nextLevel = NEXT_LEVEL[selection.level]; + if (!nextLevel) { + return; + } + + if (selection.level === 'makes') { + const make = row as CatalogMake; + setSelection({ + level: 'models', + make, + }); + return; + } + + if (selection.level === 'models') { + const model = row as CatalogModel; + const make = + selection.make ?? makesById.get(model.makeId); + setSelection({ + level: 'years', + make, + model, + }); + return; + } + + if (selection.level === 'years') { + const year = row as CatalogYear; + const model = + selection.model ?? modelsById.get(year.modelId); + const make = + selection.make ?? (model ? makesById.get(model.makeId) : undefined); + setSelection({ + level: 'trims', + make, + model, + year, + }); + return; + } + + if (selection.level === 'trims') { + const trim = row as CatalogTrim; + const year = + selection.year ?? yearsById.get(trim.yearId); + const model = + selection.model ?? + (year ? modelsById.get(year.modelId) : undefined); + const make = + selection.make ?? (model ? makesById.get(model.makeId) : undefined); + setSelection({ + level: 'engines', + make, + model, + year, + trim, + }); + } + }, + [ + selection, + makesById, + modelsById, + yearsById, + trimsById, + ] + ); + + const openCreateDialog = useCallback(() => { + if (!currentLevelRequirement.valid) { + if (currentLevelRequirement.message) { + toast.error(currentLevelRequirement.message); + } + return; + } + + setDialogState({ + open: true, + mode: 'create', + level: selection.level, + context: { ...selection }, + }); + }, [currentLevelRequirement, selection]); + + const openEditDialog = useCallback( + (row: CatalogRow) => { + const context = deriveContextFromRow(row, selection.level); + setDialogState({ + open: true, + mode: 'edit', + level: selection.level, + entity: row, + context, + }); + }, + [deriveContextFromRow, selection.level] + ); + + const deleteMutationMap = useMemo( + () => ({ + makes: deleteMake, + models: deleteModel, + years: deleteYear, + trims: deleteTrim, + engines: deleteEngine, + }), + [deleteMake, deleteModel, deleteYear, deleteTrim, deleteEngine] + ); + + const invalidateCatalogQueries = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['catalogMakes'] }); + queryClient.invalidateQueries({ queryKey: ['catalogModels'] }); + queryClient.invalidateQueries({ queryKey: ['catalogYears'] }); + queryClient.invalidateQueries({ queryKey: ['catalogTrims'] }); + queryClient.invalidateQueries({ queryKey: ['catalogEngines'] }); + }, [queryClient]); + + const handleDeleteSingle = useCallback( + async (row: CatalogRow) => { + const confirmed = window.confirm( + `Delete this ${LEVEL_SINGULAR_LABEL[selection.level]}? This action cannot be undone.` + ); + if (!confirmed) { + return; + } + + try { + await deleteMutationMap[selection.level].mutateAsync(row.id); + invalidateCatalogQueries(); + resetBulkSelection(); + } catch { + // Mutation hooks handle error messaging. + } + }, + [deleteMutationMap, selection.level, invalidateCatalogQueries, resetBulkSelection] + ); + + const handleBulkDelete = useCallback(async () => { + if (selectedCount === 0) { + return; + } + + setBulkDeleting(true); + const ids = Array.from(selected); + let deleted = 0; + let failed = 0; + + for (const id of ids) { + try { + await deleteMutationMap[selection.level].mutateAsync(id); + deleted += 1; + } catch { + failed += 1; + } + } + + invalidateCatalogQueries(); + resetBulkSelection(); + setBulkDeleting(false); + setBulkDialogOpen(false); + + if (failed > 0) { + toast.error( + `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}, failed ${failed}` + ); + } else { + toast.success( + `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}` + ); + } + }, [ + deleteMutationMap, + invalidateCatalogQueries, + resetBulkSelection, + selected, + selectedCount, + selection.level, + ]); + + const handleDialogSubmit = useCallback( + async (values: CatalogFormValues) => { + if (!dialogState) { + return; + } + + try { + if (dialogState.mode === 'create') { + switch (dialogState.level) { + case 'makes': + await createMake.mutateAsync({ + name: values.name?.trim() ?? '', + }); + break; + case 'models': + await createModel.mutateAsync({ + name: values.name?.trim() ?? '', + makeId: + values.makeId ?? + dialogState.context.make?.id ?? + '', + }); + break; + case 'years': + await createYear.mutateAsync({ + modelId: + values.modelId ?? + dialogState.context.model?.id ?? + '', + year: values.year ?? new Date().getFullYear(), + }); + break; + case 'trims': + await createTrim.mutateAsync({ + name: values.name?.trim() ?? '', + yearId: + values.yearId ?? + dialogState.context.year?.id ?? + '', + }); + break; + case 'engines': + await createEngine.mutateAsync({ + name: values.name?.trim() ?? '', + trimId: + values.trimId ?? + dialogState.context.trim?.id ?? + '', + displacement: values.displacement, + cylinders: values.cylinders, + fuel_type: values.fuel_type, + }); + break; + default: + break; + } + } else { + const entityId = dialogState.entity?.id ?? ''; + switch (dialogState.level) { + case 'makes': + await updateMake.mutateAsync({ + id: entityId, + data: { name: values.name?.trim() ?? '' }, + }); + break; + case 'models': + await updateModel.mutateAsync({ + id: entityId, + data: { name: values.name?.trim() ?? '' }, + }); + break; + case 'trims': + await updateTrim.mutateAsync({ + id: entityId, + data: { name: values.name?.trim() ?? '' }, + }); + break; + case 'engines': + await updateEngine.mutateAsync({ + id: entityId, + data: { + name: values.name?.trim(), + displacement: values.displacement, + cylinders: values.cylinders, + fuel_type: values.fuel_type, + }, + }); + break; + default: + break; + } + } + + setDialogState(null); + resetBulkSelection(); + } catch { + // Mutation hooks handle error presentation; keep dialog open for corrections. + } + }, + [ + createEngine, + createMake, + createModel, + createTrim, + createYear, + dialogState, + resetBulkSelection, + updateEngine, + updateMake, + updateModel, + updateTrim, + ] + ); + + const formatRowLabel = useCallback( + (row: CatalogRow): string => { + switch (selection.level) { + case 'makes': + return (row as CatalogMake).name; + case 'models': { + const model = row as CatalogModel; + const make = makesById.get(model.makeId); + return make ? `${model.name} (${make.name})` : model.name; + } + case 'years': { + const year = row as CatalogYear; + const model = modelsById.get(year.modelId); + return model ? `${year.year} (${model.name})` : String(year.year); + } + case 'trims': { + const trim = row as CatalogTrim; + const year = yearsById.get(trim.yearId); + return year ? `${trim.name} (${year.year})` : trim.name; + } + case 'engines': { + const engine = row as CatalogEngine; + const trim = trimsById.get(engine.trimId); + return trim + ? `${engine.name} (${trim.name})` + : engine.name; + } + default: + return row.id; + } + }, + [ + makesById, + modelsById, + selection.level, + trimsById, + yearsById, + ] + ); + + const cascadeSummary = useMemo( + () => + getCascadeSummary( + selection.level, + selectedItems, + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim + ), + [ + selection.level, + selectedItems, + modelsByMake, + yearsByModel, + trimsByYear, + enginesByTrim, + ] + ); + + const bulkDialogItems = useMemo( + () => selectedItems.map((item) => formatRowLabel(item)), + [formatRowLabel, selectedItems] + ); + + const breadcrumbs = useMemo(() => { + const items: BreadcrumbItem[] = [ + { + key: 'catalog', + label: 'Catalog', + target: { level: 'makes' }, + }, + ]; + + if (selection.make) { + items.push({ + key: 'makes', + label: 'Makes', + target: { level: 'makes' }, + }); + items.push({ + key: `make-${selection.make.id}`, + label: selection.make.name, + target: { + level: 'models', + make: selection.make, + }, + }); + } + + if (selection.model) { + items.push({ + key: 'models', + label: 'Models', + target: { + level: 'models', + make: selection.make, + }, + }); + items.push({ + key: `model-${selection.model.id}`, + label: selection.model.name, + target: { + level: 'years', + make: selection.make, + model: selection.model, + }, + }); + } + + if (selection.year) { + items.push({ + key: 'years', + label: 'Years', + target: { + level: 'years', + make: selection.make, + model: selection.model, + year: selection.year, + }, + }); + items.push({ + key: `year-${selection.year.id}`, + label: String(selection.year.year), + target: { + level: 'trims', + make: selection.make, + model: selection.model, + year: selection.year, + }, + }); + } + + if (selection.trim) { + items.push({ + key: 'trims', + label: 'Trims', + target: { + level: 'trims', + make: selection.make, + model: selection.model, + year: selection.year, + trim: selection.trim, + }, + }); + items.push({ + key: `trim-${selection.trim.id}`, + label: selection.trim.name, + target: { + level: 'engines', + make: selection.make, + model: selection.model, + year: selection.year, + trim: selection.trim, + }, + }); + } + + const currentLabel = LEVEL_LABEL[selection.level]; + const last = items[items.length - 1]; + + if (!last || last.label !== currentLabel) { + items.push({ + key: `level-${selection.level}`, + label: currentLabel, + target: { ...selection }, + }); + } + + return items; + }, [selection]); + + const contextDescription = useMemo(() => { + if (!canViewLevel) { + return currentLevelRequirement.message; + } + + switch (selection.level) { + case 'models': + return selection.make + ? `Showing models for ${selection.make.name}` + : undefined; + case 'years': + if (selection.model) { + return `Showing years for ${selection.model.name}`; + } + if (selection.make) { + return `Showing years for ${selection.make.name}`; + } + return undefined; + case 'trims': + if (selection.year) { + return `Showing trims for ${selection.year.year}`; + } + if (selection.model) { + return `Showing trims for ${selection.model.name}`; + } + return undefined; + case 'engines': + if (selection.trim) { + return `Showing engines for ${selection.trim.name}`; + } + if (selection.year) { + return `Showing engines for ${selection.year.year}`; + } + return undefined; + default: + return undefined; + } + }, [canViewLevel, currentLevelRequirement.message, selection]); + + const columns = useMemo[]>(() => { + const baseColumns: GridColumn[] = [ + { + field: selection.level === 'years' ? 'year' : 'name', + headerName: selection.level === 'years' ? 'Year' : 'Name', + sortable: true, + renderCell: (row) => + selection.level === 'years' + ? String((row as CatalogYear).year) + : (row as CatalogMake | CatalogModel | CatalogTrim | CatalogEngine).name, + }, + { + field: 'createdAt', + headerName: 'Created', + sortable: true, + renderCell: (row) => + new Date(row.createdAt).toLocaleDateString(), + }, + { + field: 'updatedAt', + headerName: 'Updated', + sortable: true, + renderCell: (row) => + new Date(row.updatedAt).toLocaleDateString(), + }, + { + field: 'actions', + headerName: 'Actions', + renderCell: (row) => ( + + {selection.level !== 'years' && selection.level !== 'engines' && ( + { + event.stopPropagation(); + openEditDialog(row); + }} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + )} + {selection.level === 'engines' && ( + { + event.stopPropagation(); + openEditDialog(row); + }} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + )} + { + event.stopPropagation(); + handleDeleteSingle(row); + }} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + {NEXT_LEVEL[selection.level] && ( + { + event.stopPropagation(); + handleDrillDown(row); + }} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + )} + + ), + }, + ]; + + return baseColumns; + }, [handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]); + + const renderTree = useCallback( + (nodes: TreeNode[], depth = 0): React.ReactNode => + nodes.map((node) => { + const isExpanded = expandedNodes.has(node.id); + const isSelected = + node.level !== undefined && selection.level === node.level; + + return ( + + { + if (node.level) { + handleLevelSelect(node.level); + } + }} + sx={{ + pl: 2 + depth * 2, + }} + > + + {node.label} + + } + /> + {node.children && ( + { + event.stopPropagation(); + toggleTreeNode(node.id); + }} + sx={{ minWidth: 36, minHeight: 36 }} + > + {isExpanded ? ( + + ) : ( + + )} + + )} + + {node.children && ( + + + {renderTree(node.children, depth + 1)} + + + )} + + ); + }), + [expandedNodes, handleLevelSelect, selection.level, toggleTreeNode] + ); + + if (authLoading) { return ( - + ); @@ -24,30 +1123,623 @@ export const AdminCatalogPage: React.FC = () => { return ; } - return ( - - - Vehicle Catalog Management - + const stats = [ + { label: 'Makes', value: makes.length }, + { label: 'Models', value: models.length }, + { label: 'Years', value: years.length }, + { label: 'Trims', value: trims.length }, + { label: 'Engines', value: engines.length }, + ]; - - - Platform Catalog - - - Vehicle catalog management interface coming soon. - - - Features: - -
    -
  • Manage vehicle makes
  • -
  • Manage vehicle models
  • -
  • Manage model years
  • -
  • Manage trims
  • -
  • Manage engine specifications
  • -
-
+ const modelsCountLabel = selection.make ? models.length.toString() : '—'; + const yearsCountLabel = selection.model ? years.length.toString() : '—'; + const trimsCountLabel = selection.year ? trims.length.toString() : '—'; + const enginesCountLabel = selection.trim ? engines.length.toString() : '—'; + + const treeNodes: TreeNode[] = [ + { + id: 'node-makes', + label: `Makes (${makes.length})`, + level: 'makes', + children: [ + { + id: 'node-models', + label: `Models (${modelsCountLabel})`, + level: 'models', + children: [ + { + id: 'node-years', + label: `Years (${yearsCountLabel})`, + level: 'years', + children: [ + { + id: 'node-trims', + label: `Trims (${trimsCountLabel})`, + level: 'trims', + children: [ + { + id: 'node-engines', + label: `Engines (${enginesCountLabel})`, + level: 'engines', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + const emptyStateMessage = canViewLevel + ? `No ${LEVEL_LABEL[selection.level].toLowerCase()} found` + : currentLevelRequirement.message ?? 'Select a parent item to continue.'; + + const bulkDialogTitle = + selectedCount === 1 + ? `Delete 1 ${LEVEL_SINGULAR_LABEL[selection.level]}?` + : `Delete ${selectedCount} ${LEVEL_LABEL[selection.level]}?`; + + return ( + + + + + {breadcrumbs.map((crumb, index) => { + const isLast = index === breadcrumbs.length - 1; + if (isLast) { + return ( + + {crumb.label} + + ); + } + return ( + setSelection({ ...crumb.target })} + sx={{ cursor: 'pointer' }} + > + {crumb.label} + + ); + })} + + + + + + Catalog Tree + + + {renderTree(treeNodes)} + + + + + toggleAll(currentRows)} + loading={activeQuery.isLoading} + error={activeError} + onRetry={() => activeQuery.refetch()} + emptyMessage={emptyStateMessage} + toolbar={ + + toggleAll(currentRows)} + > + + + + + {LEVEL_LABEL[selection.level]} + + + + {contextDescription && ( + + {contextDescription} + + )} + + } + /> + + + + + + + + { + if (!bulkDeleting) { + setBulkDialogOpen(false); + } + }} + loading={bulkDeleting} + confirmText="Delete" + /> + + {dialogState && ( + setDialogState(null)} + onSubmit={handleDialogSubmit} + context={dialogState.context} + options={{ + makes, + models, + years, + trims, + }} + /> + )} ); }; + +interface CatalogFormDialogProps { + open: boolean; + level: CatalogLevel; + mode: 'create' | 'edit'; + entity?: CatalogRow; + onClose: () => void; + onSubmit: (values: CatalogFormValues) => Promise; + context: CatalogSelectionContext; + options: { + makes: CatalogMake[]; + models: CatalogModel[]; + years: CatalogYear[]; + trims: CatalogTrim[]; + }; +} + +const CatalogFormDialog: React.FC = ({ + open, + level, + mode, + entity, + onClose, + onSubmit, + context, + options, +}) => { + const schema = useMemo(() => getSchemaForLevel(level), [level]); + + const defaultValues = useMemo( + () => buildDefaultValues(level, mode, entity, context), + [context, entity, level, mode] + ); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + watch, + } = useForm({ + resolver: zodResolver(schema), + defaultValues, + }); + + useEffect(() => { + if (open) { + reset(defaultValues); + } + }, [defaultValues, open, reset]); + + const selectedModelId = watch('modelId'); + const selectedYearId = watch('yearId'); + + const sanitizedOptions = useMemo(() => ({ + makes: normalizeCollection(options.makes, 'makes'), + models: normalizeCollection(options.models, 'models'), + years: normalizeCollection(options.years, 'years'), + trims: normalizeCollection(options.trims, 'trims'), + }), [options]); + + const availableModels = useMemo(() => { + if (context.make) { + return sanitizedOptions.models.filter( + (model) => model.makeId === context.make?.id + ); + } + return sanitizedOptions.models; + }, [context.make, sanitizedOptions.models]); + + const availableYears = useMemo(() => { + if (context.model) { + return sanitizedOptions.years.filter( + (year) => year.modelId === context.model?.id + ); + } + if (context.make) { + const modelIds = sanitizedOptions.models + .filter((model) => model.makeId === context.make?.id) + .map((model) => model.id); + return sanitizedOptions.years.filter((year) => + modelIds.includes(year.modelId) + ); + } + if (selectedModelId) { + return sanitizedOptions.years.filter( + (year) => year.modelId === selectedModelId + ); + } + return sanitizedOptions.years; + }, [ + context.make, + context.model, + sanitizedOptions.models, + sanitizedOptions.years, + selectedModelId, + ]); + + const availableTrims = useMemo(() => { + if (context.year) { + return sanitizedOptions.trims.filter( + (trim) => trim.yearId === context.year?.id + ); + } + if (selectedYearId) { + return sanitizedOptions.trims.filter( + (trim) => trim.yearId === selectedYearId + ); + } + if (context.model) { + const yearIds = sanitizedOptions.years + .filter((year) => year.modelId === context.model?.id) + .map((year) => year.id); + return sanitizedOptions.trims.filter((trim) => + yearIds.includes(trim.yearId) + ); + } + return sanitizedOptions.trims; + }, [ + context.model, + context.year, + sanitizedOptions.trims, + sanitizedOptions.years, + selectedYearId, + ]); + + const handleDialogClose = () => { + if (!isSubmitting) { + onClose(); + } + }; + + return ( + + + {mode === 'create' + ? `Create ${LEVEL_SINGULAR_LABEL[level]}` + : `Edit ${LEVEL_SINGULAR_LABEL[level]}`} + +
+ + {(level === 'makes' || + level === 'models' || + level === 'trims' || + level === 'engines') && ( + ( + field.onChange(event.target.value)} + label={`${LEVEL_SINGULAR_LABEL[level]} Name`} + fullWidth + margin="normal" + error={Boolean(errors.name)} + helperText={errors.name?.message} + autoFocus + /> + )} + /> + )} + + {level === 'models' && ( + ( + + + Select a make + + {options.makes.map((make) => ( + + {make.name} + + ))} + + )} + /> + )} + + {level === 'years' && ( + <> + ( + + + Select a model + + {availableModels.map((model) => ( + + {model.name} + + ))} + + )} + /> + ( + field.onChange(event.target.value)} + label="Year" + fullWidth + margin="normal" + type="number" + error={Boolean(errors.year)} + helperText={errors.year?.message} + /> + )} + /> + + )} + + {level === 'trims' && ( + ( + + + Select a year + + {availableYears.map((year) => ( + + {year.year} + + ))} + + )} + /> + )} + + {level === 'engines' && ( + <> + ( + + + Select a trim + + {availableTrims.map((trim) => ( + + {trim.name} + + ))} + + )} + /> + ( + field.onChange(event.target.value)} + label="Displacement (optional)" + fullWidth + margin="normal" + /> + )} + /> + ( + field.onChange(event.target.value)} + label="Cylinders (optional)" + fullWidth + margin="normal" + type="number" + error={Boolean(errors.cylinders)} + helperText={errors.cylinders?.message} + /> + )} + /> + ( + field.onChange(event.target.value)} + label="Fuel Type (optional)" + fullWidth + margin="normal" + /> + )} + /> + + )} + + + + + +
+
+ ); +}; diff --git a/test-bulk-delete.sh b/test-bulk-delete.sh new file mode 100755 index 0000000..04a6d9f --- /dev/null +++ b/test-bulk-delete.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Test script for bulk catalog delete endpoint +# Note: This is a test script to verify the endpoint structure + +BASE_URL="http://localhost/api" + +echo "Testing bulk delete catalog endpoint" +echo "====================================" +echo "" + +# Test 1: Invalid entity type +echo "Test 1: Invalid entity type (should return 400)" +curl -X DELETE "${BASE_URL}/admin/catalog/invalid/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [1, 2, 3]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 2: Empty IDs array +echo "Test 2: Empty IDs array (should return 400)" +curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": []}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 3: Invalid IDs (negative numbers) +echo "Test 3: Invalid IDs - negative numbers (should return 400)" +curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [1, -2, 3]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 4: Invalid IDs (strings instead of numbers) +echo "Test 4: Invalid IDs - strings (should return 400)" +curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": ["abc", "def"]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 5: Valid request format (will fail without auth, but shows structure) +echo "Test 5: Valid request format - makes (needs auth)" +curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [999, 998]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 6: Valid request format - models (needs auth) +echo "Test 6: Valid request format - models (needs auth)" +curl -X DELETE "${BASE_URL}/admin/catalog/models/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [999, 998]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 7: Valid request format - years (needs auth) +echo "Test 7: Valid request format - years (needs auth)" +curl -X DELETE "${BASE_URL}/admin/catalog/years/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [999, 998]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 8: Valid request format - trims (needs auth) +echo "Test 8: Valid request format - trims (needs auth)" +curl -X DELETE "${BASE_URL}/admin/catalog/trims/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [999, 998]}' \ + -w "\nStatus: %{http_code}\n\n" + +# Test 9: Valid request format - engines (needs auth) +echo "Test 9: Valid request format - engines (needs auth)" +curl -X DELETE "${BASE_URL}/admin/catalog/engines/bulk-delete" \ + -H "Content-Type: application/json" \ + -d '{"ids": [999, 998]}' \ + -w "\nStatus: %{http_code}\n\n" + +echo "====================================" +echo "Test script complete" +echo "" +echo "Expected results:" +echo "- Tests 1-4: Should return 400 (Bad Request)" +echo "- Tests 5-9: Should return 401 (Unauthorized) without auth token" +echo "" +echo "Note: Full testing requires admin authentication token"