Fix Admin Interface

This commit is contained in:
Eric Gullickson
2025-11-06 20:36:31 -06:00
parent 5630979adf
commit d30c2bad8f
6 changed files with 220 additions and 57 deletions

20
.env.development Normal file
View File

@@ -0,0 +1,20 @@
# Development Environment Variables
# This file is for local development only - NOT for production k8s deployment
# In k8s, these values come from ConfigMaps and Secrets
# Frontend Vite Configuration (build-time only)
VITE_AUTH0_DOMAIN=motovaultpro.us.auth0.com
VITE_AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3
VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com
VITE_API_BASE_URL=/api
# Docker Compose Development Configuration
# These variables are used by docker-compose for container build args only
AUTH0_DOMAIN=motovaultpro.us.auth0.com
AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3
AUTH0_AUDIENCE=https://api.motovaultpro.com
# NOTE: Backend services no longer use this file
# Backend configuration comes from:
# - /app/config/production.yml (non-sensitive config)
# - /run/secrets/ (sensitive secrets)

View File

@@ -10,24 +10,32 @@ import { PlatformCacheService } from '../../platform/domain/platform-cache.servi
export interface CatalogMake { export interface CatalogMake {
id: number; id: number;
name: string; name: string;
createdAt: string;
updatedAt: string;
} }
export interface CatalogModel { export interface CatalogModel {
id: number; id: number;
makeId: number; makeId: number;
name: string; name: string;
createdAt: string;
updatedAt: string;
} }
export interface CatalogYear { export interface CatalogYear {
id: number; id: number;
modelId: number; modelId: number;
year: number; year: number;
createdAt: string;
updatedAt: string;
} }
export interface CatalogTrim { export interface CatalogTrim {
id: number; id: number;
yearId: number; yearId: number;
name: string; name: string;
createdAt: string;
updatedAt: string;
} }
export interface CatalogEngine { export interface CatalogEngine {
@@ -35,6 +43,8 @@ export interface CatalogEngine {
trimId: number; trimId: number;
name: string; name: string;
description?: string; description?: string;
createdAt: string;
updatedAt: string;
} }
export interface PlatformChangeLog { export interface PlatformChangeLog {
@@ -58,7 +68,7 @@ export class VehicleCatalogService {
async getAllMakes(): Promise<CatalogMake[]> { async getAllMakes(): Promise<CatalogMake[]> {
const query = ` const query = `
SELECT cache_key, data SELECT cache_key, data, created_at, updated_at
FROM vehicle_dropdown_cache FROM vehicle_dropdown_cache
WHERE cache_key LIKE 'catalog:makes:%' WHERE cache_key LIKE 'catalog:makes:%'
ORDER BY (data->>'name') ORDER BY (data->>'name')
@@ -66,10 +76,18 @@ export class VehicleCatalogService {
try { try {
const result = await this.pool.query(query); const result = await this.pool.query(query);
return result.rows.map(row => ({ return result.rows.map(row => {
const createdAt =
row.data?.createdAt ?? this.toIsoDate(row.created_at);
const updatedAt =
row.data?.updatedAt ?? this.toIsoDate(row.updated_at);
return {
id: parseInt(row.cache_key.split(':')[2]), id: parseInt(row.cache_key.split(':')[2]),
name: row.data.name name: row.data.name,
})); createdAt,
updatedAt,
};
});
} catch (error) { } catch (error) {
logger.error('Error getting all makes', { error }); logger.error('Error getting all makes', { error });
throw error; throw error;
@@ -91,7 +109,8 @@ export class VehicleCatalogService {
const makeId = idResult.rows[0].next_id; const makeId = idResult.rows[0].next_id;
// Insert make // Insert make
const make: CatalogMake = { id: makeId, name }; const now = new Date().toISOString();
const make: CatalogMake = { id: makeId, name, createdAt: now, updatedAt: now };
await client.query(` await client.query(`
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '10 years') VALUES ($1, $2, NOW() + INTERVAL '10 years')
@@ -124,15 +143,23 @@ export class VehicleCatalogService {
// Get old value // Get old value
const oldResult = await client.query(` const oldResult = await client.query(`
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
`, [`catalog:makes:${makeId}`]); `, [`catalog:makes:${makeId}`]);
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
throw new Error(`Make ${makeId} not found`); throw new Error(`Make ${makeId} not found`);
} }
const oldValue = oldResult.rows[0].data; const oldRow = oldResult.rows[0];
const newValue: CatalogMake = { id: makeId, name }; const oldValue = oldRow.data;
const createdAt =
oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
const newValue: CatalogMake = {
id: makeId,
name,
createdAt,
updatedAt: new Date().toISOString(),
};
// Update make // Update make
await client.query(` await client.query(`
@@ -216,7 +243,7 @@ export class VehicleCatalogService {
async getModelsByMake(makeId: number): Promise<CatalogModel[]> { async getModelsByMake(makeId: number): Promise<CatalogModel[]> {
const query = ` const query = `
SELECT cache_key, data SELECT cache_key, data, created_at, updated_at
FROM vehicle_dropdown_cache FROM vehicle_dropdown_cache
WHERE cache_key LIKE 'catalog:models:%' WHERE cache_key LIKE 'catalog:models:%'
AND (data->>'makeId')::int = $1 AND (data->>'makeId')::int = $1
@@ -228,7 +255,11 @@ export class VehicleCatalogService {
return result.rows.map(row => ({ return result.rows.map(row => ({
id: parseInt(row.cache_key.split(':')[2]), id: parseInt(row.cache_key.split(':')[2]),
makeId: row.data.makeId, makeId: row.data.makeId,
name: row.data.name name: row.data.name,
createdAt:
row.data?.createdAt ?? this.toIsoDate(row.created_at),
updatedAt:
row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting models by make', { error, makeId }); logger.error('Error getting models by make', { error, makeId });
@@ -260,7 +291,14 @@ export class VehicleCatalogService {
const modelId = idResult.rows[0].next_id; const modelId = idResult.rows[0].next_id;
// Insert model // Insert model
const model: CatalogModel = { id: modelId, makeId, name }; const now = new Date().toISOString();
const model: CatalogModel = {
id: modelId,
makeId,
name,
createdAt: now,
updatedAt: now,
};
await client.query(` await client.query(`
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '10 years') VALUES ($1, $2, NOW() + INTERVAL '10 years')
@@ -302,15 +340,24 @@ export class VehicleCatalogService {
// Get old value // Get old value
const oldResult = await client.query(` const oldResult = await client.query(`
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
`, [`catalog:models:${modelId}`]); `, [`catalog:models:${modelId}`]);
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
throw new Error(`Model ${modelId} not found`); throw new Error(`Model ${modelId} not found`);
} }
const oldValue = oldResult.rows[0].data; const oldRow = oldResult.rows[0];
const newValue: CatalogModel = { id: modelId, makeId, name }; const oldValue = oldRow.data;
const createdAt =
oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
const newValue: CatalogModel = {
id: modelId,
makeId,
name,
createdAt,
updatedAt: new Date().toISOString(),
};
// Update model // Update model
await client.query(` await client.query(`
@@ -394,7 +441,7 @@ export class VehicleCatalogService {
async getYearsByModel(modelId: number): Promise<CatalogYear[]> { async getYearsByModel(modelId: number): Promise<CatalogYear[]> {
const query = ` const query = `
SELECT cache_key, data SELECT cache_key, data, created_at, updated_at
FROM vehicle_dropdown_cache FROM vehicle_dropdown_cache
WHERE cache_key LIKE 'catalog:years:%' WHERE cache_key LIKE 'catalog:years:%'
AND (data->>'modelId')::int = $1 AND (data->>'modelId')::int = $1
@@ -406,7 +453,11 @@ export class VehicleCatalogService {
return result.rows.map(row => ({ return result.rows.map(row => ({
id: parseInt(row.cache_key.split(':')[2]), id: parseInt(row.cache_key.split(':')[2]),
modelId: row.data.modelId, modelId: row.data.modelId,
year: row.data.year year: row.data.year,
createdAt:
row.data?.createdAt ?? this.toIsoDate(row.created_at),
updatedAt:
row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting years by model', { error, modelId }); logger.error('Error getting years by model', { error, modelId });
@@ -438,7 +489,14 @@ export class VehicleCatalogService {
const yearId = idResult.rows[0].next_id; const yearId = idResult.rows[0].next_id;
// Insert year // Insert year
const yearData: CatalogYear = { id: yearId, modelId, year }; const now = new Date().toISOString();
const yearData: CatalogYear = {
id: yearId,
modelId,
year,
createdAt: now,
updatedAt: now,
};
await client.query(` await client.query(`
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '10 years') VALUES ($1, $2, NOW() + INTERVAL '10 years')
@@ -480,15 +538,24 @@ export class VehicleCatalogService {
// Get old value // Get old value
const oldResult = await client.query(` const oldResult = await client.query(`
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
`, [`catalog:years:${yearId}`]); `, [`catalog:years:${yearId}`]);
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
throw new Error(`Year ${yearId} not found`); throw new Error(`Year ${yearId} not found`);
} }
const oldValue = oldResult.rows[0].data; const oldRow = oldResult.rows[0];
const newValue: CatalogYear = { id: yearId, modelId, year }; const oldValue = oldRow.data;
const createdAt =
oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
const newValue: CatalogYear = {
id: yearId,
modelId,
year,
createdAt,
updatedAt: new Date().toISOString(),
};
// Update year // Update year
await client.query(` await client.query(`
@@ -572,7 +639,7 @@ export class VehicleCatalogService {
async getTrimsByYear(yearId: number): Promise<CatalogTrim[]> { async getTrimsByYear(yearId: number): Promise<CatalogTrim[]> {
const query = ` const query = `
SELECT cache_key, data SELECT cache_key, data, created_at, updated_at
FROM vehicle_dropdown_cache FROM vehicle_dropdown_cache
WHERE cache_key LIKE 'catalog:trims:%' WHERE cache_key LIKE 'catalog:trims:%'
AND (data->>'yearId')::int = $1 AND (data->>'yearId')::int = $1
@@ -584,7 +651,11 @@ export class VehicleCatalogService {
return result.rows.map(row => ({ return result.rows.map(row => ({
id: parseInt(row.cache_key.split(':')[2]), id: parseInt(row.cache_key.split(':')[2]),
yearId: row.data.yearId, yearId: row.data.yearId,
name: row.data.name name: row.data.name,
createdAt:
row.data?.createdAt ?? this.toIsoDate(row.created_at),
updatedAt:
row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting trims by year', { error, yearId }); logger.error('Error getting trims by year', { error, yearId });
@@ -616,7 +687,14 @@ export class VehicleCatalogService {
const trimId = idResult.rows[0].next_id; const trimId = idResult.rows[0].next_id;
// Insert trim // Insert trim
const trim: CatalogTrim = { id: trimId, yearId, name }; const now = new Date().toISOString();
const trim: CatalogTrim = {
id: trimId,
yearId,
name,
createdAt: now,
updatedAt: now,
};
await client.query(` await client.query(`
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '10 years') VALUES ($1, $2, NOW() + INTERVAL '10 years')
@@ -658,15 +736,24 @@ export class VehicleCatalogService {
// Get old value // Get old value
const oldResult = await client.query(` const oldResult = await client.query(`
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
`, [`catalog:trims:${trimId}`]); `, [`catalog:trims:${trimId}`]);
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
throw new Error(`Trim ${trimId} not found`); throw new Error(`Trim ${trimId} not found`);
} }
const oldValue = oldResult.rows[0].data; const oldRow = oldResult.rows[0];
const newValue: CatalogTrim = { id: trimId, yearId, name }; const oldValue = oldRow.data;
const createdAt =
oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
const newValue: CatalogTrim = {
id: trimId,
yearId,
name,
createdAt,
updatedAt: new Date().toISOString(),
};
// Update trim // Update trim
await client.query(` await client.query(`
@@ -750,7 +837,7 @@ export class VehicleCatalogService {
async getEnginesByTrim(trimId: number): Promise<CatalogEngine[]> { async getEnginesByTrim(trimId: number): Promise<CatalogEngine[]> {
const query = ` const query = `
SELECT cache_key, data SELECT cache_key, data, created_at, updated_at
FROM vehicle_dropdown_cache FROM vehicle_dropdown_cache
WHERE cache_key LIKE 'catalog:engines:%' WHERE cache_key LIKE 'catalog:engines:%'
AND (data->>'trimId')::int = $1 AND (data->>'trimId')::int = $1
@@ -763,7 +850,11 @@ export class VehicleCatalogService {
id: parseInt(row.cache_key.split(':')[2]), id: parseInt(row.cache_key.split(':')[2]),
trimId: row.data.trimId, trimId: row.data.trimId,
name: row.data.name, name: row.data.name,
description: row.data.description description: row.data.description,
createdAt:
row.data?.createdAt ?? this.toIsoDate(row.created_at),
updatedAt:
row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting engines by trim', { error, trimId }); logger.error('Error getting engines by trim', { error, trimId });
@@ -795,7 +886,15 @@ export class VehicleCatalogService {
const engineId = idResult.rows[0].next_id; const engineId = idResult.rows[0].next_id;
// Insert engine // Insert engine
const engine: CatalogEngine = { id: engineId, trimId, name, description }; const now = new Date().toISOString();
const engine: CatalogEngine = {
id: engineId,
trimId,
name,
description,
createdAt: now,
updatedAt: now,
};
await client.query(` await client.query(`
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '10 years') VALUES ($1, $2, NOW() + INTERVAL '10 years')
@@ -837,15 +936,25 @@ export class VehicleCatalogService {
// Get old value // Get old value
const oldResult = await client.query(` const oldResult = await client.query(`
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
`, [`catalog:engines:${engineId}`]); `, [`catalog:engines:${engineId}`]);
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
throw new Error(`Engine ${engineId} not found`); throw new Error(`Engine ${engineId} not found`);
} }
const oldValue = oldResult.rows[0].data; const oldRow = oldResult.rows[0];
const newValue: CatalogEngine = { id: engineId, trimId, name, description }; const oldValue = oldRow.data;
const createdAt =
oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
const newValue: CatalogEngine = {
id: engineId,
trimId,
name,
description,
createdAt,
updatedAt: new Date().toISOString(),
};
// Update engine // Update engine
await client.query(` await client.query(`
@@ -915,6 +1024,17 @@ export class VehicleCatalogService {
// HELPER METHODS // HELPER METHODS
private toIsoDate(value: Date | string | null | undefined): string {
if (!value) {
return new Date().toISOString();
}
if (value instanceof Date) {
return value.toISOString();
}
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? new Date().toISOString() : parsed.toISOString();
}
private async logChange( private async logChange(
client: any, client: any,
changeType: 'CREATE' | 'UPDATE' | 'DELETE', changeType: 'CREATE' | 'UPDATE' | 'DELETE',

View File

@@ -28,6 +28,11 @@ import {
UpdateStationRequest, UpdateStationRequest,
} from '../types/admin.types'; } from '../types/admin.types';
export interface AuditLogsResponse {
logs: AdminAuditLog[];
total: number;
}
// Admin access verification // Admin access verification
export const adminApi = { export const adminApi = {
// Verify admin access // Verify admin access
@@ -56,8 +61,8 @@ export const adminApi = {
}, },
// Audit logs // Audit logs
listAuditLogs: async (): Promise<AdminAuditLog[]> => { listAuditLogs: async (): Promise<AuditLogsResponse> => {
const response = await apiClient.get<AdminAuditLog[]>('/admin/audit-logs'); const response = await apiClient.get<AuditLogsResponse>('/admin/audit-logs');
return response.data; return response.data;
}, },

View File

@@ -30,11 +30,11 @@ export const makeSchema = z.object({
export const modelSchema = z.object({ export const modelSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
makeId: z.string().min(1, 'Select a make'), makeId: z.coerce.string().min(1, 'Select a make'),
}); });
export const yearSchema = z.object({ export const yearSchema = z.object({
modelId: z.string().min(1, 'Select a model'), modelId: z.coerce.string().min(1, 'Select a model'),
year: z year: z
.coerce.number() .coerce.number()
.int() .int()
@@ -44,12 +44,12 @@ export const yearSchema = z.object({
export const trimSchema = z.object({ export const trimSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
yearId: z.string().min(1, 'Select a year'), yearId: z.coerce.string().min(1, 'Select a year'),
}); });
export const engineSchema = z.object({ export const engineSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
trimId: z.string().min(1, 'Select a trim'), trimId: z.coerce.string().min(1, 'Select a trim'),
displacement: z.string().optional(), displacement: z.string().optional(),
cylinders: z cylinders: z
.preprocess( .preprocess(
@@ -96,22 +96,22 @@ export const buildDefaultValues = (
case 'models': case 'models':
return { return {
name: (entity as CatalogModel).name, name: (entity as CatalogModel).name,
makeId: (entity as CatalogModel).makeId, makeId: String((entity as CatalogModel).makeId),
}; };
case 'years': case 'years':
return { return {
modelId: (entity as CatalogYear).modelId, modelId: String((entity as CatalogYear).modelId),
year: (entity as CatalogYear).year, year: (entity as CatalogYear).year,
}; };
case 'trims': case 'trims':
return { return {
name: (entity as CatalogTrim).name, name: (entity as CatalogTrim).name,
yearId: (entity as CatalogTrim).yearId, yearId: String((entity as CatalogTrim).yearId),
}; };
case 'engines': case 'engines':
return { return {
name: (entity as CatalogEngine).name, name: (entity as CatalogEngine).name,
trimId: (entity as CatalogEngine).trimId, trimId: String((entity as CatalogEngine).trimId),
displacement: (entity as CatalogEngine).displacement ?? undefined, displacement: (entity as CatalogEngine).displacement ?? undefined,
cylinders: (entity as CatalogEngine).cylinders ?? undefined, cylinders: (entity as CatalogEngine).cylinders ?? undefined,
fuel_type: (entity as CatalogEngine).fuel_type ?? undefined, fuel_type: (entity as CatalogEngine).fuel_type ?? undefined,
@@ -125,22 +125,22 @@ export const buildDefaultValues = (
case 'models': case 'models':
return { return {
name: '', name: '',
makeId: context.make?.id ?? '', makeId: context.make?.id ? String(context.make.id) : '',
}; };
case 'years': case 'years':
return { return {
modelId: context.model?.id ?? '', modelId: context.model?.id ? String(context.model.id) : '',
year: undefined, year: undefined,
}; };
case 'trims': case 'trims':
return { return {
name: '', name: '',
yearId: context.year?.id ?? '', yearId: context.year?.id ? String(context.year.id) : '',
}; };
case 'engines': case 'engines':
return { return {
name: '', name: '',
trimId: context.trim?.id ?? '', trimId: context.trim?.id ? String(context.trim.id) : '',
displacement: '', displacement: '',
fuel_type: '', fuel_type: '',
}; };

View File

@@ -6,7 +6,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { adminApi } from '../api/admin.api'; import { adminApi, AuditLogsResponse } from '../api/admin.api';
import { AdminAuditLog } from '../types/admin.types'; import { AdminAuditLog } from '../types/admin.types';
/** /**
@@ -67,11 +67,11 @@ export function useAuditLogStream(
// Query for fetching audit logs // Query for fetching audit logs
const { const {
data: rawLogs = [], data: auditLogResponse,
isLoading, isLoading,
error, error,
refetch, refetch,
} = useQuery({ } = useQuery<AuditLogsResponse>({
queryKey: ['auditLogs', resourceType, offset, limit], queryKey: ['auditLogs', resourceType, offset, limit],
queryFn: async () => { queryFn: async () => {
const logs = await adminApi.listAuditLogs(); const logs = await adminApi.listAuditLogs();
@@ -87,21 +87,29 @@ export function useAuditLogStream(
}); });
// Filter logs by resource type if specified // Filter logs by resource type if specified
const rawLogs = auditLogResponse?.logs;
const logs = Array.isArray(rawLogs) ? rawLogs : [];
const totalFromApi =
typeof auditLogResponse?.total === 'number'
? auditLogResponse.total
: logs.length;
const filteredLogs = resourceType const filteredLogs = resourceType
? rawLogs.filter((log) => log.resourceType === resourceType) ? logs.filter((log) => log.resourceType === resourceType)
: rawLogs; : logs;
// Apply pagination // Apply pagination
const paginatedLogs = filteredLogs.slice(offset, offset + limit); const paginatedLogs = filteredLogs.slice(offset, offset + limit);
// Calculate pagination state // Calculate pagination state
const paginationTotal = resourceType ? filteredLogs.length : totalFromApi;
const pagination: PaginationState = { const pagination: PaginationState = {
offset, offset,
limit, limit,
total: filteredLogs.length, total: paginationTotal,
}; };
const hasMore = offset + limit < filteredLogs.length; const hasMore = offset + limit < paginationTotal;
/** /**
* Navigate to next page * Navigate to next page

View File

@@ -958,6 +958,16 @@ export const AdminCatalogPage: React.FC = () => {
} }
}, [canViewLevel, currentLevelRequirement.message, selection]); }, [canViewLevel, currentLevelRequirement.message, selection]);
const formatDateValue = useCallback((value?: string) => {
if (!value) {
return '—';
}
const date = new Date(value);
return Number.isNaN(date.valueOf())
? '—'
: date.toLocaleDateString();
}, []);
const columns = useMemo<GridColumn<CatalogRow>[]>(() => { const columns = useMemo<GridColumn<CatalogRow>[]>(() => {
const baseColumns: GridColumn<CatalogRow>[] = [ const baseColumns: GridColumn<CatalogRow>[] = [
{ {
@@ -974,14 +984,14 @@ export const AdminCatalogPage: React.FC = () => {
headerName: 'Created', headerName: 'Created',
sortable: true, sortable: true,
renderCell: (row) => renderCell: (row) =>
new Date(row.createdAt).toLocaleDateString(), formatDateValue(row.createdAt),
}, },
{ {
field: 'updatedAt', field: 'updatedAt',
headerName: 'Updated', headerName: 'Updated',
sortable: true, sortable: true,
renderCell: (row) => renderCell: (row) =>
new Date(row.updatedAt).toLocaleDateString(), formatDateValue(row.updatedAt),
}, },
{ {
field: 'actions', field: 'actions',
@@ -1041,7 +1051,7 @@ export const AdminCatalogPage: React.FC = () => {
]; ];
return baseColumns; return baseColumns;
}, [handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]); }, [formatDateValue, handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]);
const renderTree = useCallback( const renderTree = useCallback(
(nodes: TreeNode[], depth = 0): React.ReactNode => (nodes: TreeNode[], depth = 0): React.ReactNode =>