From b0e392fef1249e18ee286606d46cdd611b0d42f0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:29:54 -0600 Subject: [PATCH] feat: add type-specific metadata and expiration badges to documents UX (refs #43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ExpirationBadge component with 30-day warning and expired states - Create DocumentCardMetadata component for type-specific field display - Update DocumentsPage to show metadata and expiration badges on cards - Update DocumentsMobileScreen with metadata and badges (mobile variant) - Redesign DocumentDetailPage with side-by-side layout (desktop) and stacked layout (mobile) showing full metadata panel - Add 33 unit tests for new components - Fix jest.config.ts testMatch pattern for test discovery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/jest.config.ts | 4 +- .../components/DocumentCardMetadata.test.tsx | 246 ++++++++++++++++++ .../components/DocumentCardMetadata.tsx | 171 ++++++++++++ .../components/ExpirationBadge.test.tsx | 109 ++++++++ .../documents/components/ExpirationBadge.tsx | 60 +++++ .../mobile/DocumentsMobileScreen.tsx | 8 +- .../documents/pages/DocumentDetailPage.tsx | 189 ++++++++++---- .../documents/pages/DocumentsPage.tsx | 8 +- 8 files changed, 742 insertions(+), 53 deletions(-) create mode 100644 frontend/src/features/documents/components/DocumentCardMetadata.test.tsx create mode 100644 frontend/src/features/documents/components/DocumentCardMetadata.tsx create mode 100644 frontend/src/features/documents/components/ExpirationBadge.test.tsx create mode 100644 frontend/src/features/documents/components/ExpirationBadge.tsx diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 389a3e1..6ef7f47 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -2,7 +2,6 @@ * Jest configuration for MotoVaultPro frontend (React + TS, ESM) */ import type { Config } from 'jest'; -const { createDefaultPreset } = require('ts-jest/presets'); const tsJestTransformCfg = { tsconfig: 'tsconfig.json', @@ -14,7 +13,6 @@ const config: Config = { roots: ['/src'], extensionsToTreatAsEsm: ['.ts', '.tsx'], transform: { - ...createDefaultPreset().transform, '^.+\\.(ts|tsx)$': ['ts-jest', tsJestTransformCfg], }, moduleNameMapper: { @@ -23,7 +21,7 @@ const config: Config = { '\\.(svg|png|jpg|jpeg|gif)$': '/test/__mocks__/fileMock.js', }, setupFilesAfterEnv: ['/setupTests.ts'], - testMatch: ['**/?(*.)+(test).[tj]sx?'], + testMatch: ['**/*.test.ts', '**/*.test.tsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], testPathIgnorePatterns: ['/node_modules/', '/dist/'], reporters: [ diff --git a/frontend/src/features/documents/components/DocumentCardMetadata.test.tsx b/frontend/src/features/documents/components/DocumentCardMetadata.test.tsx new file mode 100644 index 0000000..af83794 --- /dev/null +++ b/frontend/src/features/documents/components/DocumentCardMetadata.test.tsx @@ -0,0 +1,246 @@ +import { render, screen } from '@testing-library/react'; +import { DocumentCardMetadata } from './DocumentCardMetadata'; +import type { DocumentRecord } from '../types/documents.types'; + +const baseDocument: DocumentRecord = { + id: 'doc-1', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', + title: 'Test Document', + sharedVehicleIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('DocumentCardMetadata', () => { + describe('insurance documents', () => { + it('displays expiration date', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + }; + render(); + expect(screen.getByText('06/15/2025')).toBeInTheDocument(); + expect(screen.getByText('Expires:')).toBeInTheDocument(); + }); + + it('displays policy number', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + details: { policyNumber: 'POL-12345' }, + }; + render(); + expect(screen.getByText('POL-12345')).toBeInTheDocument(); + expect(screen.getByText('Policy #:')).toBeInTheDocument(); + }); + + it('displays insurance company', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + details: { insuranceCompany: 'Acme Insurance' }, + }; + render(); + expect(screen.getByText('Acme Insurance')).toBeInTheDocument(); + expect(screen.getByText('Company:')).toBeInTheDocument(); + }); + + it('limits to 3 fields in card variant', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + issuedDate: '2024-06-15', + details: { + policyNumber: 'POL-12345', + insuranceCompany: 'Acme Insurance', + premium: 1200.5, + }, + }; + render(); + // Should show first 3: expiration, policy, company + expect(screen.getByText('06/15/2025')).toBeInTheDocument(); + expect(screen.getByText('POL-12345')).toBeInTheDocument(); + expect(screen.getByText('Acme Insurance')).toBeInTheDocument(); + // Premium should not be shown in card variant (4th item) + expect(screen.queryByText('$1200.50')).not.toBeInTheDocument(); + }); + + it('shows all fields in detail variant', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + issuedDate: '2024-06-15', + details: { + policyNumber: 'POL-12345', + insuranceCompany: 'Acme Insurance', + bodilyInjuryPerson: '$50,000', + bodilyInjuryIncident: '$100,000', + propertyDamage: '$25,000', + premium: 1200.5, + }, + }; + render(); + expect(screen.getByText('06/15/2025')).toBeInTheDocument(); + expect(screen.getByText('POL-12345')).toBeInTheDocument(); + expect(screen.getByText('Acme Insurance')).toBeInTheDocument(); + expect(screen.getByText('$50,000')).toBeInTheDocument(); + expect(screen.getByText('$100,000')).toBeInTheDocument(); + expect(screen.getByText('$25,000')).toBeInTheDocument(); + expect(screen.getByText('$1200.50')).toBeInTheDocument(); + }); + }); + + describe('registration documents', () => { + it('displays expiration date', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'registration', + expirationDate: '2025-12-31', + }; + render(); + expect(screen.getByText('12/31/2025')).toBeInTheDocument(); + }); + + it('displays license plate', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'registration', + details: { licensePlate: 'ABC 123' }, + }; + render(); + expect(screen.getByText('ABC 123')).toBeInTheDocument(); + expect(screen.getByText('Plate:')).toBeInTheDocument(); + }); + + it('shows cost in detail variant only', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'registration', + details: { cost: 150.0 }, + }; + const { rerender } = render(); + expect(screen.queryByText('$150.00')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('$150.00')).toBeInTheDocument(); + }); + }); + + describe('manual documents', () => { + it('displays issued date if set', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'manual', + issuedDate: '2020-01-15', + }; + render(); + expect(screen.getByText('01/15/2020')).toBeInTheDocument(); + expect(screen.getByText('Issued:')).toBeInTheDocument(); + }); + + it('shows notes preview in detail variant only', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'manual', + notes: 'This is a test note about the manual', + }; + const { rerender } = render(); + expect(screen.queryByText(/This is a test note/)).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('This is a test note about the manual')).toBeInTheDocument(); + }); + + it('truncates long notes in detail variant', () => { + const longNote = 'A'.repeat(150); + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'manual', + notes: longNote, + }; + render(); + expect(screen.getByText(/^A{100}\.\.\.$/)).toBeInTheDocument(); + }); + }); + + describe('empty states', () => { + it('returns null when no metadata to display', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'manual', + details: null, + }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('handles missing details gracefully', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + details: undefined, + }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('variant styling', () => { + it('uses text-xs for mobile variant', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + }; + const { container } = render(); + expect(container.firstChild).toHaveClass('text-xs'); + }); + + it('uses text-sm for card variant', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + }; + const { container } = render(); + expect(container.firstChild).toHaveClass('text-sm'); + }); + + it('uses grid layout for detail variant', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + expirationDate: '2025-06-15', + }; + const { container } = render(); + expect(container.firstChild).toHaveClass('grid'); + }); + }); + + describe('currency formatting', () => { + it('formats premium correctly', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'insurance', + details: { premium: 1234.56 }, + }; + render(); + expect(screen.getByText('$1234.56')).toBeInTheDocument(); + }); + + it('handles string numbers', () => { + const doc: DocumentRecord = { + ...baseDocument, + documentType: 'registration', + details: { cost: '99.99' }, + }; + render(); + expect(screen.getByText('$99.99')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/features/documents/components/DocumentCardMetadata.tsx b/frontend/src/features/documents/components/DocumentCardMetadata.tsx new file mode 100644 index 0000000..26bd0d3 --- /dev/null +++ b/frontend/src/features/documents/components/DocumentCardMetadata.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import type { DocumentRecord, DocumentType } from '../types/documents.types'; + +interface DocumentCardMetadataProps { + /** The document record containing type-specific metadata */ + doc: DocumentRecord; + /** Display variant: card (desktop list), mobile, or detail (full view) */ + variant: 'card' | 'mobile' | 'detail'; +} + +/** + * Displays type-specific metadata for documents. + * Adapts display based on document type and variant. + */ +export const DocumentCardMetadata: React.FC = ({ + doc, + variant, +}) => { + const formatDate = (date: string | null | undefined): string | null => { + if (!date) return null; + return dayjs(date).format('MM/DD/YYYY'); + }; + + const formatCurrency = (value: number | string | null | undefined): string | null => { + if (value === null || value === undefined || value === '') return null; + const num = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(num)) return null; + return `$${num.toFixed(2)}`; + }; + + const isCompact = variant === 'card' || variant === 'mobile'; + const textSize = variant === 'mobile' ? 'text-xs' : 'text-sm'; + const labelColor = 'text-slate-500 dark:text-titanio'; + const valueColor = 'text-slate-700 dark:text-avus'; + + // Extract details with type safety + const details = doc.details || {}; + + const renderInsuranceMetadata = () => { + const items: Array<{ label: string; value: string | null }> = []; + + // Always show expiration if available + if (doc.expirationDate) { + items.push({ label: 'Expires', value: formatDate(doc.expirationDate) }); + } + + // Policy number + if (details.policyNumber) { + items.push({ label: 'Policy #', value: details.policyNumber }); + } + + // Insurance company + if (details.insuranceCompany) { + items.push({ label: 'Company', value: details.insuranceCompany }); + } + + // Additional fields for detail view + if (!isCompact) { + if (doc.issuedDate) { + items.push({ label: 'Effective', value: formatDate(doc.issuedDate) }); + } + if (details.bodilyInjuryPerson) { + items.push({ label: 'Bodily Injury (Person)', value: details.bodilyInjuryPerson }); + } + if (details.bodilyInjuryIncident) { + items.push({ label: 'Bodily Injury (Incident)', value: details.bodilyInjuryIncident }); + } + if (details.propertyDamage) { + items.push({ label: 'Property Damage', value: details.propertyDamage }); + } + if (details.premium) { + items.push({ label: 'Premium', value: formatCurrency(details.premium) }); + } + } + + // For compact, limit to 3-4 fields + const displayItems = isCompact ? items.slice(0, 3) : items; + return displayItems.filter((item) => item.value !== null); + }; + + const renderRegistrationMetadata = () => { + const items: Array<{ label: string; value: string | null }> = []; + + // Always show expiration if available + if (doc.expirationDate) { + items.push({ label: 'Expires', value: formatDate(doc.expirationDate) }); + } + + // License plate + if (details.licensePlate) { + items.push({ label: 'Plate', value: details.licensePlate }); + } + + // Additional fields for detail view + if (!isCompact) { + if (details.cost) { + items.push({ label: 'Cost', value: formatCurrency(details.cost) }); + } + } + + return items.filter((item) => item.value !== null); + }; + + const renderManualMetadata = () => { + const items: Array<{ label: string; value: string | null }> = []; + + // Issue date if set + if (doc.issuedDate) { + items.push({ label: 'Issued', value: formatDate(doc.issuedDate) }); + } + + // Notes preview for detail view + if (!isCompact && doc.notes) { + const notesPreview = + doc.notes.length > 100 ? doc.notes.substring(0, 100) + '...' : doc.notes; + items.push({ label: 'Notes', value: notesPreview }); + } + + return items.filter((item) => item.value !== null); + }; + + const getMetadataItems = ( + type: DocumentType + ): Array<{ label: string; value: string | null }> => { + switch (type) { + case 'insurance': + return renderInsuranceMetadata(); + case 'registration': + return renderRegistrationMetadata(); + case 'manual': + return renderManualMetadata(); + default: + return []; + } + }; + + const items = getMetadataItems(doc.documentType); + + if (items.length === 0) { + return null; + } + + // Detail variant uses grid layout + if (variant === 'detail') { + return ( +
+ {items.map((item, index) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ ); + } + + // Compact variant (card/mobile) uses inline display + return ( +
+ {items.map((item, index) => ( +
+ {item.label}:{' '} + {item.value} +
+ ))} +
+ ); +}; + +export default DocumentCardMetadata; diff --git a/frontend/src/features/documents/components/ExpirationBadge.test.tsx b/frontend/src/features/documents/components/ExpirationBadge.test.tsx new file mode 100644 index 0000000..587b6a7 --- /dev/null +++ b/frontend/src/features/documents/components/ExpirationBadge.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import dayjs from 'dayjs'; +import { ExpirationBadge } from './ExpirationBadge'; + +describe('ExpirationBadge', () => { + describe('when no expiration date is provided', () => { + it('renders nothing for null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing for undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing for empty string', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('when document is expired', () => { + it('shows "Expired" badge for past dates', () => { + const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('shows "Expired" badge for dates far in the past', () => { + const pastDate = dayjs().subtract(1, 'year').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('has red styling for expired badge', () => { + const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + render(); + const badge = screen.getByText('Expired'); + expect(badge).toHaveClass('bg-red-100', 'text-red-800'); + }); + }); + + describe('when document expires today', () => { + it('shows "Expires today" badge', () => { + const today = dayjs().format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expires today')).toBeInTheDocument(); + }); + + it('has amber styling for expiring soon badge', () => { + const today = dayjs().format('YYYY-MM-DD'); + render(); + const badge = screen.getByText('Expires today'); + expect(badge).toHaveClass('bg-amber-100', 'text-amber-800'); + }); + }); + + describe('when document expires tomorrow', () => { + it('shows "Expires tomorrow" badge', () => { + const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expires tomorrow')).toBeInTheDocument(); + }); + }); + + describe('when document expires within 30 days', () => { + it('shows "Expires in X days" badge for 15 days', () => { + const in15Days = dayjs().add(15, 'day').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expires in 15 days')).toBeInTheDocument(); + }); + + it('shows "Expires in X days" badge for 30 days', () => { + const in30Days = dayjs().add(30, 'day').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expires in 30 days')).toBeInTheDocument(); + }); + + it('shows "Expires in X days" badge for 2 days', () => { + const in2Days = dayjs().add(2, 'day').format('YYYY-MM-DD'); + render(); + expect(screen.getByText('Expires in 2 days')).toBeInTheDocument(); + }); + }); + + describe('when document expires after 30 days', () => { + it('renders nothing for 31 days out', () => { + const in31Days = dayjs().add(31, 'day').format('YYYY-MM-DD'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing for dates far in the future', () => { + const nextYear = dayjs().add(1, 'year').format('YYYY-MM-DD'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('className prop', () => { + it('applies custom className to the badge', () => { + const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + render(); + const badge = screen.getByText('Expired'); + expect(badge).toHaveClass('custom-class'); + }); + }); +}); diff --git a/frontend/src/features/documents/components/ExpirationBadge.tsx b/frontend/src/features/documents/components/ExpirationBadge.tsx new file mode 100644 index 0000000..9ff1bf0 --- /dev/null +++ b/frontend/src/features/documents/components/ExpirationBadge.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import dayjs from 'dayjs'; + +interface ExpirationBadgeProps { + /** The expiration date in ISO format (YYYY-MM-DD) */ + expirationDate: string | null | undefined; + /** Additional CSS classes */ + className?: string; +} + +/** + * Displays a warning badge for documents expiring within 30 days or already expired. + * Returns null if no expiration date is provided. + */ +export const ExpirationBadge: React.FC = ({ + expirationDate, + className = '', +}) => { + if (!expirationDate) { + return null; + } + + const today = dayjs().startOf('day'); + const expDate = dayjs(expirationDate).startOf('day'); + const daysUntilExpiration = expDate.diff(today, 'day'); + + // Already expired + if (daysUntilExpiration < 0) { + return ( + + Expired + + ); + } + + // Expiring within 30 days + if (daysUntilExpiration <= 30) { + const label = + daysUntilExpiration === 0 + ? 'Expires today' + : daysUntilExpiration === 1 + ? 'Expires tomorrow' + : `Expires in ${daysUntilExpiration} days`; + + return ( + + {label} + + ); + } + + // Not expiring soon - no badge + return null; +}; + +export default ExpirationBadge; diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index fc2a42c..5924465 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -7,6 +7,8 @@ import { useDocumentsList } from '../hooks/useDocuments'; import { useUploadWithProgress } from '../hooks/useUploadWithProgress'; import { Button } from '../../../shared-minimal/components/Button'; import { AddDocumentDialog } from '../components/AddDocumentDialog'; +import { ExpirationBadge } from '../components/ExpirationBadge'; +import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { getVehicleLabel } from '../utils/vehicleLabel'; @@ -181,11 +183,15 @@ export const DocumentsMobileScreen: React.FC = () => { return (
-
{doc.title}
+
+ {doc.title} + +
{doc.documentType} {isShared && ' • Shared'}
+ -
- {doc.sharedVehicleIds.length > 0 && ( -
-
Shared with:
-
    - {doc.sharedVehicleIds.map((vehicleId) => { - const sharedVehicle = vehiclesMap.get(vehicleId); - return ( -
  • - -
  • - ); - })} -
+ + {/* Mobile Layout: Stacked */} +
+ {/* Header Card - Mobile */} + +
+
+

{doc.title}

+
- )} -
+
+ {doc.documentType} + | + +
+ +
+ + + {/* Preview Card - Mobile */} + +
-
- - - -
- {upload.isPending && ( -
Uploading... {upload.progress}%
- )} - {upload.isError && ( -
- {((upload.error as any)?.response?.status === 415) - ? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.' - : 'Failed to upload file. Please try again.'} + + + {/* Actions Card - Mobile */} + +
+
+ + +
- )} + {upload.isPending && ( +
Uploading... {upload.progress}%
+ )} + {upload.isError && ( +
+ {((upload.error as any)?.response?.status === 415) + ? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.' + : 'Failed to upload file. Please try again.'} +
+ )} +
+
+
+ + {/* Desktop Layout: Side by Side */} +
+ {/* Left Panel: Document Preview (60%) */} +
+ +
+ +
+
- + + {/* Right Panel: Metadata (40%) */} +
+ +
+ {/* Title and Badge */} +
+
+

{doc.title}

+ +
+
+ + {/* Document Type */} +
+
Type
+
{doc.documentType}
+
+ + {/* Vehicle */} +
+
Vehicle
+ +
+ + {/* Shared Vehicles */} + {doc.sharedVehicleIds.length > 0 && ( +
+
Shared with
+
    + {doc.sharedVehicleIds.map((vehicleId) => { + const sharedVehicle = vehiclesMap.get(vehicleId); + return ( +
  • + +
  • + ); + })} +
+
+ )} + + {/* Type-specific Metadata */} +
+
Details
+ +
+ + {/* Actions */} +
+
+ + + +
+ {upload.isPending && ( +
Uploading... {upload.progress}%
+ )} + {upload.isError && ( +
+ {((upload.error as any)?.response?.status === 415) + ? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.' + : 'Failed to upload file. Please try again.'} +
+ )} +
+
+
+
+
+ {doc && ( { return (
-
{doc.title}
+
+ {doc.title} + +
Type: {doc.documentType}
+
Vehicle: