Admin Page work - Still blank/broken
This commit is contained in:
134
frontend/src/features/admin/__tests__/catalogShared.test.ts
Normal file
134
frontend/src/features/admin/__tests__/catalogShared.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> => {
|
||||
|
||||
151
frontend/src/features/admin/catalog/catalogSchemas.ts
Normal file
151
frontend/src/features/admin/catalog/catalogSchemas.ts
Normal 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: '' };
|
||||
}
|
||||
};
|
||||
157
frontend/src/features/admin/catalog/catalogShared.ts
Normal file
157
frontend/src/features/admin/catalog/catalogShared.ts
Normal 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 '';
|
||||
};
|
||||
253
frontend/src/features/admin/components/AdminDataGrid.tsx
Normal file
253
frontend/src/features/admin/components/AdminDataGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
94
frontend/src/features/admin/components/AdminSkeleton.tsx
Normal file
94
frontend/src/features/admin/components/AdminSkeleton.tsx
Normal 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,
|
||||
};
|
||||
224
frontend/src/features/admin/components/AuditLogDrawer.tsx
Normal file
224
frontend/src/features/admin/components/AuditLogDrawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
219
frontend/src/features/admin/components/AuditLogPanel.tsx
Normal file
219
frontend/src/features/admin/components/AuditLogPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
137
frontend/src/features/admin/components/BulkActionDialog.tsx
Normal file
137
frontend/src/features/admin/components/BulkActionDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
frontend/src/features/admin/components/EmptyState.tsx
Normal file
106
frontend/src/features/admin/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
frontend/src/features/admin/components/ErrorState.tsx
Normal file
73
frontend/src/features/admin/components/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
frontend/src/features/admin/components/SelectionToolbar.tsx
Normal file
105
frontend/src/features/admin/components/SelectionToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
frontend/src/features/admin/components/index.ts
Normal file
30
frontend/src/features/admin/components/index.ts
Normal 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';
|
||||
140
frontend/src/features/admin/hooks/useAuditLogStream.ts
Normal file
140
frontend/src/features/admin/hooks/useAuditLogStream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
114
frontend/src/features/admin/hooks/useBulkSelection.ts
Normal file
114
frontend/src/features/admin/hooks/useBulkSelection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user