Admin Page work - Still blank/broken

This commit is contained in:
Eric Gullickson
2025-11-06 16:29:11 -06:00
parent 858cf31d38
commit 5630979adf
38 changed files with 7373 additions and 924 deletions

View File

@@ -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<string, CatalogModel[]>([
[baseMake.id, [baseModel]],
]);
const yearsByModel = new Map<string, CatalogYear[]>([
[baseModel.id, [baseYear]],
]);
const trimsByYear = new Map<string, CatalogTrim[]>([
[baseYear.id, [baseTrim]],
]);
const enginesByTrim = new Map<string, CatalogEngine[]>([
[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');
});
});

View File

@@ -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(
<AdminSectionHeader
title="Vehicle Catalog"
stats={[
{ label: 'Makes', value: 100 },
{ label: 'Models', value: 500 },
{ label: 'Years', value: 20 },
]}
/>
);
expect(container).toMatchSnapshot();
});
it('should render with empty stats', () => {
const { container } = render(
<AdminSectionHeader title="Admin Users" stats={[]} />
);
expect(container).toMatchSnapshot();
});
it('should format large numbers with locale', () => {
const { container } = render(
<AdminSectionHeader
title="Station Management"
stats={[{ label: 'Total Stations', value: 10000 }]}
/>
);
expect(container).toMatchSnapshot();
});
});

View File

@@ -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(<AdminSkeleton.SkeletonRow />);
expect(container).toMatchSnapshot();
});
it('should render specified number of rows', () => {
const { container } = render(<AdminSkeleton.SkeletonRow count={5} />);
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(<AdminSkeleton.SkeletonCard />);
expect(container).toMatchSnapshot();
});
it('should render specified number of cards', () => {
const { container } = render(<AdminSkeleton.SkeletonCard count={4} />);
expect(container).toMatchSnapshot();
expect(container.querySelectorAll('.MuiCard-root')).toHaveLength(4);
});
});
});

View File

@@ -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(<BulkActionDialog {...defaultProps} />);
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(<BulkActionDialog {...defaultProps} />);
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(<BulkActionDialog {...defaultProps} onConfirm={handleConfirm} />);
fireEvent.click(screen.getByText('Confirm'));
expect(handleConfirm).toHaveBeenCalledTimes(1);
});
it('should call onCancel when cancel button clicked', () => {
const handleCancel = jest.fn();
render(<BulkActionDialog {...defaultProps} onCancel={handleCancel} />);
fireEvent.click(screen.getByText('Cancel'));
expect(handleCancel).toHaveBeenCalledTimes(1);
});
it('should disable buttons when loading', () => {
render(<BulkActionDialog {...defaultProps} loading={true} />);
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(
<BulkActionDialog {...defaultProps} loading={true} />
);
expect(container.querySelector('.MuiCircularProgress-root')).toBeInTheDocument();
});
it('should support custom button text', () => {
render(
<BulkActionDialog
{...defaultProps}
confirmText="Delete Now"
cancelText="Go Back"
/>
);
expect(screen.getByText('Delete Now')).toBeInTheDocument();
expect(screen.getByText('Go Back')).toBeInTheDocument();
});
});

View File

@@ -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(
<EmptyState
title="No Data"
description="Start by adding your first item"
/>
);
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(
<EmptyState
icon={<div data-testid="test-icon">Icon</div>}
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(
<EmptyState
title="No Items"
description="Add your first item"
action={{ label: 'Add Item', onClick: handleAction }}
/>
);
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(<EmptyState title="Empty" description="No data" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -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(<ErrorState error={error} />);
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(<ErrorState error={error} onRetry={handleRetry} />);
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(<ErrorState error={error} />);
expect(screen.queryByText('Retry')).not.toBeInTheDocument();
});
it('should show default message when error has no message', () => {
const error = new Error();
render(<ErrorState error={error} />);
expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument();
});
});

View File

@@ -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(
<SelectionToolbar selectedCount={0} onClear={jest.fn()} />
);
expect(container.firstChild).toBeNull();
});
it('should render when items are selected', () => {
const { container } = render(
<SelectionToolbar selectedCount={3} onClear={jest.fn()} />
);
expect(container).toMatchSnapshot();
expect(screen.getByText('Selected: 3')).toBeInTheDocument();
});
it('should call onClear when Clear button clicked', () => {
const handleClear = jest.fn();
render(<SelectionToolbar selectedCount={2} onClear={handleClear} />);
fireEvent.click(screen.getByText('Clear'));
expect(handleClear).toHaveBeenCalledTimes(1);
});
it('should call onSelectAll when Select All button clicked', () => {
const handleSelectAll = jest.fn();
render(
<SelectionToolbar
selectedCount={2}
onSelectAll={handleSelectAll}
onClear={jest.fn()}
/>
);
fireEvent.click(screen.getByText('Select All'));
expect(handleSelectAll).toHaveBeenCalledTimes(1);
});
it('should render custom action buttons', () => {
const { container } = render(
<SelectionToolbar selectedCount={3} onClear={jest.fn()}>
<button>Delete</button>
<button>Export</button>
</SelectionToolbar>
);
expect(container).toMatchSnapshot();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
});

View File

@@ -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);
});
});

View File

@@ -63,8 +63,8 @@ export const adminApi = {
// Catalog - Makes
listMakes: async (): Promise<CatalogMake[]> => {
const response = await apiClient.get<CatalogMake[]>('/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<CatalogMake> => {
@@ -82,10 +82,11 @@ export const adminApi = {
},
// Catalog - Models
listModels: async (makeId?: string): Promise<CatalogModel[]> => {
const url = makeId ? `/admin/catalog/models?make_id=${makeId}` : '/admin/catalog/models';
const response = await apiClient.get<CatalogModel[]>(url);
return response.data;
listModels: async (makeId: string): Promise<CatalogModel[]> => {
const response = await apiClient.get<{ models: CatalogModel[] }>(
`/admin/catalog/makes/${makeId}/models`
);
return response.data.models;
},
createModel: async (data: CreateCatalogModelRequest): Promise<CatalogModel> => {
@@ -103,10 +104,11 @@ export const adminApi = {
},
// Catalog - Years
listYears: async (modelId?: string): Promise<CatalogYear[]> => {
const url = modelId ? `/admin/catalog/years?model_id=${modelId}` : '/admin/catalog/years';
const response = await apiClient.get<CatalogYear[]>(url);
return response.data;
listYears: async (modelId: string): Promise<CatalogYear[]> => {
const response = await apiClient.get<{ years: CatalogYear[] }>(
`/admin/catalog/models/${modelId}/years`
);
return response.data.years;
},
createYear: async (data: CreateCatalogYearRequest): Promise<CatalogYear> => {
@@ -119,10 +121,11 @@ export const adminApi = {
},
// Catalog - Trims
listTrims: async (yearId?: string): Promise<CatalogTrim[]> => {
const url = yearId ? `/admin/catalog/trims?year_id=${yearId}` : '/admin/catalog/trims';
const response = await apiClient.get<CatalogTrim[]>(url);
return response.data;
listTrims: async (yearId: string): Promise<CatalogTrim[]> => {
const response = await apiClient.get<{ trims: CatalogTrim[] }>(
`/admin/catalog/years/${yearId}/trims`
);
return response.data.trims;
},
createTrim: async (data: CreateCatalogTrimRequest): Promise<CatalogTrim> => {
@@ -140,10 +143,11 @@ export const adminApi = {
},
// Catalog - Engines
listEngines: async (trimId?: string): Promise<CatalogEngine[]> => {
const url = trimId ? `/admin/catalog/engines?trim_id=${trimId}` : '/admin/catalog/engines';
const response = await apiClient.get<CatalogEngine[]>(url);
return response.data;
listEngines: async (trimId: string): Promise<CatalogEngine[]> => {
const response = await apiClient.get<{ engines: CatalogEngine[] }>(
`/admin/catalog/trims/${trimId}/engines`
);
return response.data.engines;
},
createEngine: async (data: CreateCatalogEngineRequest): Promise<CatalogEngine> => {

View File

@@ -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: '' };
}
};

View File

@@ -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<CatalogLevel, string> = {
makes: 'Makes',
models: 'Models',
years: 'Years',
trims: 'Trims',
engines: 'Engines',
};
export const LEVEL_SINGULAR_LABEL: Record<CatalogLevel, string> = {
makes: 'Make',
models: 'Model',
years: 'Year',
trims: 'Trim',
engines: 'Engine',
};
export const NEXT_LEVEL: Record<CatalogLevel, CatalogLevel | null> = {
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<string, CatalogModel[]>,
yearsByModel: Map<string, CatalogYear[]>,
trimsByYear: Map<string, CatalogTrim[]>,
enginesByTrim: Map<string, CatalogEngine[]>
): 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 '';
};

View File

@@ -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<T = any> {
field: keyof T | string;
headerName: string;
sortable?: boolean;
width?: string | number;
renderCell?: (row: T) => React.ReactNode;
}
/**
* Props for AdminDataGrid component
*/
export interface AdminDataGridProps<T = any> {
rows: T[];
columns: GridColumn<T>[];
selectedIds: Set<string>;
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
* <AdminDataGrid
* rows={data}
* columns={[
* { field: 'name', headerName: 'Name', sortable: true },
* { field: 'createdAt', headerName: 'Created', renderCell: (row) => formatDate(row.createdAt) }
* ]}
* selectedIds={selected}
* onSelectChange={toggleItem}
* onSelectAll={toggleAll}
* />
* ```
*/
export function AdminDataGrid<T extends Record<string, any>>({
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<T>): React.ReactElement {
// Loading state
if (loading) {
return (
<Box>
{toolbar}
<AdminSkeleton.SkeletonRow count={5} />
</Box>
);
}
// Error state
if (error) {
return (
<Box>
{toolbar}
<ErrorState error={error} onRetry={onRetry} />
</Box>
);
}
// Empty state
if (rows.length === 0) {
return (
<Box>
{toolbar}
<EmptyState
title="No Data"
description={emptyMessage}
icon={null}
/>
</Box>
);
}
const allSelected =
rows.length > 0 && rows.every((row) => selectedIds.has(getRowId(row)));
const someSelected =
rows.some((row) => selectedIds.has(getRowId(row))) && !allSelected;
return (
<Box>
{toolbar}
<TableContainer component={Paper} sx={{ boxShadow: 1 }}>
<Table>
<TableHead>
<TableRow>
{/* Checkbox column */}
<TableCell padding="checkbox">
{onSelectAll && (
<Checkbox
indeterminate={someSelected}
checked={allSelected}
onChange={onSelectAll}
inputProps={{
'aria-label': 'select all',
}}
/>
)}
</TableCell>
{/* Data columns */}
{columns.map((column) => (
<TableCell
key={String(column.field)}
sx={{
width: column.width,
fontWeight: 600,
}}
>
{column.sortable ? (
<TableSortLabel>
{column.headerName}
</TableSortLabel>
) : (
column.headerName
)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const rowId = getRowId(row);
const isSelected = selectedIds.has(rowId);
return (
<TableRow
key={rowId}
selected={isSelected}
hover
sx={{
cursor: 'pointer',
'&.Mui-selected': {
backgroundColor: 'action.selected',
},
}}
>
{/* Checkbox cell */}
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
onChange={() => onSelectChange(rowId)}
inputProps={{
'aria-label': `select row ${rowId}`,
}}
sx={{
minWidth: 44,
minHeight: 44,
}}
/>
</TableCell>
{/* Data cells */}
{columns.map((column) => (
<TableCell key={String(column.field)}>
{column.renderCell
? column.renderCell(row)
: row[column.field]}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{/* Pagination controls */}
{onPageChange && totalPages > 1 && (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
mt: 2,
gap: 1,
}}
>
<Typography variant="body2" color="text.secondary">
Page {page + 1} of {totalPages}
</Typography>
<IconButton
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
)}
</Box>
);
}

View File

@@ -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
* <AdminSectionHeader
* title="Vehicle Catalog"
* stats={[
* { label: 'Makes', value: 100 },
* { label: 'Models', value: 500 }
* ]}
* />
* ```
*/
export const AdminSectionHeader: React.FC<AdminSectionHeaderProps> = ({
title,
stats,
}) => {
return (
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
component="h1"
sx={{
mb: 3,
fontWeight: 600,
color: 'text.primary',
}}
>
{title}
</Typography>
<Grid container spacing={2}>
{stats.map((stat) => (
<Grid item xs={12} sm={6} md={3} key={stat.label}>
<Card
sx={{
height: '100%',
boxShadow: 1,
transition: 'box-shadow 0.2s',
'&:hover': {
boxShadow: 2,
},
}}
>
<CardContent>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1 }}
>
{stat.label}
</Typography>
<Typography
variant="h5"
component="div"
sx={{ fontWeight: 600 }}
>
{stat.value.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
};

View File

@@ -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
* <AdminSkeleton.SkeletonRow count={5} />
* ```
*/
const SkeletonRow: React.FC<SkeletonRowProps> = ({ count = 3 }) => {
return (
<Box sx={{ p: 2 }}>
{Array.from({ length: count }).map((_, index) => (
<Box
key={index}
sx={{
display: 'flex',
gap: 2,
mb: 2,
alignItems: 'center',
}}
>
<Skeleton variant="rectangular" width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={24} />
<Skeleton variant="text" width="40%" height={20} />
</Box>
<Skeleton variant="rectangular" width={80} height={32} />
</Box>
))}
</Box>
);
};
/**
* Props for SkeletonCard component
*/
interface SkeletonCardProps {
count?: number;
}
/**
* Skeleton loading cards for mobile views
*
* @example
* ```tsx
* <AdminSkeleton.SkeletonCard count={3} />
* ```
*/
const SkeletonCard: React.FC<SkeletonCardProps> = ({ count = 3 }) => {
return (
<Box>
{Array.from({ length: count }).map((_, index) => (
<Card key={index} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="rectangular" width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={20} />
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant="rectangular" width={80} height={36} />
<Skeleton variant="rectangular" width={80} height={36} />
</Box>
</CardContent>
</Card>
))}
</Box>
);
};
/**
* Admin skeleton loading components
*/
export const AdminSkeleton = {
SkeletonRow,
SkeletonCard,
};

View File

@@ -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
* <AuditLogDrawer
* open={drawerOpen}
* onClose={() => setDrawerOpen(false)}
* resourceType="station"
* limit={50}
* />
* ```
*/
export const AuditLogDrawer: React.FC<AuditLogDrawerProps> = ({
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 (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
sx: {
maxHeight: '80vh',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
}}
>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Audit Logs
</Typography>
<Typography variant="caption" color="text.secondary">
{resourceLabel}
</Typography>
</Box>
<IconButton
onClick={onClose}
aria-label="close"
sx={{ minWidth: 44, minHeight: 44 }}
>
<Close />
</IconButton>
</Box>
{/* Content */}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 200 }}>
{loading && <AdminSkeleton.SkeletonCard count={3} />}
{error && <ErrorState error={error} onRetry={refetch} />}
{!loading && !error && logs.length === 0 && (
<EmptyState
title="No Audit Logs"
description="No activity recorded yet"
icon={null}
/>
)}
{!loading && !error && logs.length > 0 && (
<List>
{logs.map((log) => (
<React.Fragment key={log.id}>
<ListItem
alignItems="flex-start"
sx={{
minHeight: 64,
}}
>
<ListItemText
primary={
<Typography
variant="body1"
sx={{ fontWeight: 600, mb: 0.5 }}
>
{log.action}
</Typography>
}
secondary={
<Box>
<Typography
variant="body2"
component="span"
color="text.secondary"
>
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</Typography>
{log.resourceType && (
<Typography
variant="body2"
component="span"
color="text.secondary"
sx={{ ml: 1 }}
>
| {log.resourceType}
</Typography>
)}
</Box>
}
/>
</ListItem>
<Divider component="li" />
</React.Fragment>
))}
</List>
)}
</Box>
{/* Footer with pagination */}
{!loading && logs.length > 0 && (
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: 1,
borderColor: 'divider',
minHeight: 64,
}}
>
<Typography variant="body2" color="text.secondary">
Updated{' '}
{formatDistanceToNow(lastUpdated, {
addSuffix: true,
})}
</Typography>
<Box>
<IconButton
onClick={prevPage}
disabled={pagination.offset === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
onClick={nextPage}
disabled={!hasMore}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
</Box>
)}
</Box>
</Drawer>
);
};

View File

@@ -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
* <AuditLogPanel
* resourceType="catalog"
* limit={50}
* pollIntervalMs={5000}
* />
* ```
*/
export const AuditLogPanel: React.FC<AuditLogPanelProps> = ({
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 (
<Paper
sx={{
width: 320,
height: 'fit-content',
maxHeight: 'calc(100vh - 200px)',
display: 'flex',
flexDirection: 'column',
boxShadow: 2,
}}
>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Audit Logs
</Typography>
<Typography variant="caption" color="text.secondary">
{resourceLabel} (Last {limit})
</Typography>
</Box>
<IconButton
onClick={() => setCollapsed(!collapsed)}
size="small"
aria-label={collapsed ? 'expand' : 'collapse'}
sx={{ minWidth: 44, minHeight: 44 }}
>
{collapsed ? <ExpandMore /> : <ExpandLess />}
</IconButton>
</Box>
{/* Content */}
<Collapse in={!collapsed}>
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 200 }}>
{loading && <AdminSkeleton.SkeletonRow count={3} />}
{error && <ErrorState error={error} onRetry={refetch} />}
{!loading && !error && logs.length === 0 && (
<EmptyState
title="No Audit Logs"
description="No activity recorded yet"
icon={null}
/>
)}
{!loading && !error && logs.length > 0 && (
<List dense>
{logs.map((log) => (
<React.Fragment key={log.id}>
<ListItem alignItems="flex-start">
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{log.action}
</Typography>
}
secondary={
<Box>
<Typography
variant="caption"
component="span"
color="text.secondary"
>
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</Typography>
{log.resourceType && (
<Typography
variant="caption"
component="span"
color="text.secondary"
sx={{ ml: 1 }}
>
| {log.resourceType}
</Typography>
)}
</Box>
}
/>
</ListItem>
<Divider component="li" />
</React.Fragment>
))}
</List>
)}
</Box>
{/* Footer with pagination */}
{!loading && logs.length > 0 && (
<Box
sx={{
p: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: 1,
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Updated{' '}
{formatDistanceToNow(lastUpdated, {
addSuffix: true,
})}
</Typography>
<Box>
<IconButton
size="small"
onClick={prevPage}
disabled={pagination.offset === 0}
aria-label="previous page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronLeft />
</IconButton>
<IconButton
size="small"
onClick={nextPage}
disabled={!hasMore}
aria-label="next page"
sx={{ minWidth: 44, minHeight: 44 }}
>
<ChevronRight />
</IconButton>
</Box>
</Box>
)}
</Collapse>
</Paper>
);
};

View File

@@ -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
* <BulkActionDialog
* open={open}
* title="Delete 3 makes?"
* message="This will delete 15 dependent models. Continue?"
* items={['Honda', 'Toyota', 'Ford']}
* onConfirm={handleConfirm}
* onCancel={handleCancel}
* loading={deleting}
* />
* ```
*/
export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
open,
title,
message,
items,
onConfirm,
onCancel,
loading = false,
confirmText = 'Confirm',
cancelText = 'Cancel',
}) => {
return (
<Dialog
open={open}
onClose={loading ? undefined : onCancel}
maxWidth="sm"
fullWidth
aria-labelledby="bulk-action-dialog-title"
>
<DialogTitle id="bulk-action-dialog-title">{title}</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
{message}
</Typography>
{items.length > 0 && (
<Box
sx={{
maxHeight: 200,
overflow: 'auto',
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
}}
>
<List dense>
{items.map((item, index) => (
<ListItem key={index}>
<ListItemText
primary={item}
primaryTypographyProps={{
variant: 'body2',
}}
/>
</ListItem>
))}
</List>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={onCancel}
disabled={loading}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{cancelText}
</Button>
<Button
onClick={onConfirm}
disabled={loading}
variant="contained"
color="error"
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
confirmText
)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -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
* <EmptyState
* icon={<InboxIcon fontSize="large" />}
* title="No Data"
* description="Start by adding your first item"
* action={{ label: 'Add Item', onClick: handleAdd }}
* />
* ```
*/
export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
}) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 2,
textAlign: 'center',
}}
>
{icon && (
<Box
sx={{
mb: 3,
color: 'text.secondary',
opacity: 0.5,
fontSize: 64,
}}
>
{icon}
</Box>
)}
<Typography
variant="h6"
component="h2"
sx={{
mb: 1,
fontWeight: 600,
color: 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: action ? 3 : 0, maxWidth: 400 }}
>
{description}
</Typography>
{action && (
<Button
variant="contained"
onClick={action.onClick}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
{action.label}
</Button>
)}
</Box>
);
};

View File

@@ -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
* <ErrorState
* error={new Error('Failed to load data')}
* onRetry={refetch}
* />
* ```
*/
export const ErrorState: React.FC<ErrorStateProps> = ({ error, onRetry }) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 2,
}}
>
<Alert
severity="error"
sx={{
mb: 3,
maxWidth: 600,
width: '100%',
}}
>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
An error occurred
</Typography>
<Typography variant="body2">
{error.message || 'Something went wrong. Please try again.'}
</Typography>
</Alert>
{onRetry && (
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={onRetry}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Retry
</Button>
)}
</Box>
);
};

View File

@@ -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
* <SelectionToolbar
* selectedCount={3}
* onSelectAll={selectAll}
* onClear={reset}
* >
* <Button onClick={handleDelete}>Delete</Button>
* <Button onClick={handleExport}>Export</Button>
* </SelectionToolbar>
* ```
*/
export const SelectionToolbar: React.FC<SelectionToolbarProps> = ({
selectedCount,
onSelectAll,
onClear,
children,
}) => {
// Only show toolbar if items are selected
if (selectedCount === 0) {
return null;
}
return (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: 'action.selected',
borderRadius: 1,
mb: 2,
minHeight: { xs: 56, sm: 64 },
}}
>
<Typography
variant="subtitle1"
component="div"
sx={{
flex: '1 1 100%',
fontWeight: 600,
}}
>
Selected: {selectedCount}
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{onSelectAll && (
<Button
size="small"
onClick={onSelectAll}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Select All
</Button>
)}
<Button
size="small"
onClick={onClear}
sx={{
minWidth: 44,
minHeight: 44,
textTransform: 'none',
}}
>
Clear
</Button>
{children}
</Box>
</Toolbar>
);
};

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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<T> {
items: T[];
keyExtractor?: (item: T) => string;
}
/**
* Return type for bulk selection hook
*/
interface UseBulkSelectionReturn<T> {
selected: Set<string>;
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<T extends { id: string }>(
options: UseBulkSelectionOptions<T>
): UseBulkSelectionReturn<T> {
const { items, keyExtractor = (item: T) => item.id } = options;
const [selected, setSelected] = useState<Set<string>>(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,
};
}

View File

@@ -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,

File diff suppressed because it is too large Load Diff