Merge pull request 'feat: Enhance Documents UX with detail view, type-specific cards, and expiration alerts (#43)' (#44) from issue-43-documents-ux-enhancement into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 6m54s
Deploy to Staging / Deploy to Staging (push) Successful in 29s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 6m54s
Deploy to Staging / Deploy to Staging (push) Successful in 29s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
@@ -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: ['<rootDir>/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)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/setupTests.ts'],
|
||||
testMatch: ['**/?(*.)+(test).[tj]sx?'],
|
||||
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
reporters: [
|
||||
|
||||
@@ -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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
// 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(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
expect(screen.getByText('12/31/2025')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays license plate', () => {
|
||||
const doc: DocumentRecord = {
|
||||
...baseDocument,
|
||||
documentType: 'registration',
|
||||
details: { licensePlate: 'ABC 123' },
|
||||
};
|
||||
render(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
expect(screen.queryByText('$150.00')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
expect(screen.queryByText(/This is a test note/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('handles missing details gracefully', () => {
|
||||
const doc: DocumentRecord = {
|
||||
...baseDocument,
|
||||
documentType: 'insurance',
|
||||
details: undefined,
|
||||
};
|
||||
const { container } = render(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="mobile" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="card" />);
|
||||
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(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
expect(container.firstChild).toHaveClass('grid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('currency formatting', () => {
|
||||
it('formats premium correctly', () => {
|
||||
const doc: DocumentRecord = {
|
||||
...baseDocument,
|
||||
documentType: 'insurance',
|
||||
details: { premium: 1234.56 },
|
||||
};
|
||||
render(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
expect(screen.getByText('$1234.56')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles string numbers', () => {
|
||||
const doc: DocumentRecord = {
|
||||
...baseDocument,
|
||||
documentType: 'registration',
|
||||
details: { cost: '99.99' },
|
||||
};
|
||||
render(<DocumentCardMetadata doc={doc} variant="detail" />);
|
||||
expect(screen.getByText('$99.99')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<DocumentCardMetadataProps> = ({
|
||||
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 (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex flex-col">
|
||||
<span className={`${textSize} ${labelColor}`}>{item.label}</span>
|
||||
<span className={`${textSize} font-medium ${valueColor}`}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact variant (card/mobile) uses inline display
|
||||
return (
|
||||
<div className={`${textSize} ${labelColor} space-y-0.5`}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<span className="font-medium">{item.label}:</span>{' '}
|
||||
<span className={valueColor}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentCardMetadata;
|
||||
@@ -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(<ExpirationBadge expirationDate={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing for undefined', () => {
|
||||
const { container } = render(<ExpirationBadge expirationDate={undefined} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing for empty string', () => {
|
||||
const { container } = render(<ExpirationBadge expirationDate="" />);
|
||||
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(<ExpirationBadge expirationDate={yesterday} />);
|
||||
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(<ExpirationBadge expirationDate={pastDate} />);
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has red styling for expired badge', () => {
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
render(<ExpirationBadge expirationDate={yesterday} />);
|
||||
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(<ExpirationBadge expirationDate={today} />);
|
||||
expect(screen.getByText('Expires today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has amber styling for expiring soon badge', () => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
render(<ExpirationBadge expirationDate={today} />);
|
||||
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(<ExpirationBadge expirationDate={tomorrow} />);
|
||||
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(<ExpirationBadge expirationDate={in15Days} />);
|
||||
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(<ExpirationBadge expirationDate={in30Days} />);
|
||||
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(<ExpirationBadge expirationDate={in2Days} />);
|
||||
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(<ExpirationBadge expirationDate={in31Days} />);
|
||||
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(<ExpirationBadge expirationDate={nextYear} />);
|
||||
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(<ExpirationBadge expirationDate={yesterday} className="custom-class" />);
|
||||
const badge = screen.getByText('Expired');
|
||||
expect(badge).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ExpirationBadgeProps> = ({
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 ${className}`}
|
||||
>
|
||||
Expired
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Expiring within 30 days
|
||||
if (daysUntilExpiration <= 30) {
|
||||
const label =
|
||||
daysUntilExpiration === 0
|
||||
? 'Expires today'
|
||||
: daysUntilExpiration === 1
|
||||
? 'Expires tomorrow'
|
||||
: `Expires in ${daysUntilExpiration} days`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 ${className}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Not expiring soon - no badge
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ExpirationBadge;
|
||||
@@ -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 (
|
||||
<div key={doc.id} className="border rounded-xl p-3 space-y-2">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800 dark:text-avus">{doc.title}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-slate-800 dark:text-avus">{doc.title}</span>
|
||||
<ExpirationBadge expirationDate={doc.expirationDate} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-titanio">
|
||||
{doc.documentType}
|
||||
{isShared && ' • Shared'}
|
||||
</div>
|
||||
<DocumentCardMetadata doc={doc} variant="mobile" />
|
||||
<button
|
||||
onClick={() => navigate(`/garage/vehicles/${doc.vehicleId}`)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 underline min-h-[44px] inline-flex items-center"
|
||||
@@ -194,7 +200,7 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>View Details</Button>
|
||||
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
|
||||
{upload.isPending && currentId === doc.id && (
|
||||
<span className="text-xs text-slate-500">{upload.progress}%</span>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
|
||||
import { documentsApi } from '../api/documents.api';
|
||||
import { DocumentPreview } from '../components/DocumentPreview';
|
||||
import { EditDocumentDialog } from '../components/EditDocumentDialog';
|
||||
import { ExpirationBadge } from '../components/ExpirationBadge';
|
||||
import { DocumentCardMetadata } from '../components/DocumentCardMetadata';
|
||||
import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import { getVehicleLabel } from '../utils/vehicleLabel';
|
||||
|
||||
@@ -25,6 +27,25 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
|
||||
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
|
||||
|
||||
// Check if document has displayable metadata
|
||||
const hasDisplayableMetadata = useMemo(() => {
|
||||
if (!doc) return false;
|
||||
const details = doc.details || {};
|
||||
|
||||
if (doc.documentType === 'insurance') {
|
||||
return !!(doc.expirationDate || details.policyNumber || details.insuranceCompany ||
|
||||
doc.issuedDate || details.bodilyInjuryPerson || details.bodilyInjuryIncident ||
|
||||
details.propertyDamage || details.premium);
|
||||
}
|
||||
if (doc.documentType === 'registration') {
|
||||
return !!(doc.expirationDate || details.licensePlate || details.cost);
|
||||
}
|
||||
if (doc.documentType === 'manual') {
|
||||
return !!(doc.issuedDate || doc.notes);
|
||||
}
|
||||
return false;
|
||||
}, [doc]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!id) return;
|
||||
const blob = await documentsApi.download(id);
|
||||
@@ -145,12 +166,91 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<input ref={inputRef} type="file" accept="image/jpeg,image/png,application/pdf" capture="environment" className="hidden" />
|
||||
|
||||
{/* Mobile Layout: Stacked */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{/* Header Card - Mobile */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-semibold">{doc.title}</h2>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Vehicle: </span>
|
||||
<ExpirationBadge expirationDate={doc.expirationDate} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-titanio">
|
||||
<span className="capitalize">{doc.documentType}</span>
|
||||
<span>|</span>
|
||||
<button
|
||||
onClick={() => navigate(`/garage/vehicles/${doc.vehicleId}`)}
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{getVehicleLabel(vehicle)}
|
||||
</button>
|
||||
</div>
|
||||
{hasDisplayableMetadata && <DocumentCardMetadata doc={doc} variant="mobile" />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Preview Card - Mobile */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions Card - Mobile */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleDownload} className="min-h-[44px]">Download</Button>
|
||||
<Button onClick={handleUpload} className="min-h-[44px]">Upload/Replace</Button>
|
||||
<Button onClick={() => setIsEditOpen(true)} variant="secondary" className="min-h-[44px]">Edit</Button>
|
||||
</div>
|
||||
{upload.isPending && (
|
||||
<div className="text-sm text-slate-600 dark:text-titanio">Uploading... {upload.progress}%</div>
|
||||
)}
|
||||
{upload.isError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{((upload.error as any)?.response?.status === 415)
|
||||
? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'
|
||||
: 'Failed to upload file. Please try again.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout: Side by Side */}
|
||||
<div className="hidden md:flex md:gap-6">
|
||||
{/* Left Panel: Document Preview (60%) */}
|
||||
<div className="flex-[3]">
|
||||
<Card className="h-full">
|
||||
<div className="p-4">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Metadata (40%) */}
|
||||
<div className="flex-[2]">
|
||||
<Card>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Title and Badge */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-semibold">{doc.title}</h2>
|
||||
<ExpirationBadge expirationDate={doc.expirationDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Type */}
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 dark:text-titanio">Type</div>
|
||||
<div className="font-medium capitalize">{doc.documentType}</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle */}
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 dark:text-titanio">Vehicle</div>
|
||||
<button
|
||||
onClick={() => navigate(`/garage/vehicles/${doc.vehicleId}`)}
|
||||
className="text-blue-600 hover:text-blue-800 underline min-h-[44px] inline-flex items-center"
|
||||
@@ -158,17 +258,19 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
{getVehicleLabel(vehicle)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shared Vehicles */}
|
||||
{doc.sharedVehicleIds.length > 0 && (
|
||||
<div className="text-sm text-slate-500 space-y-1">
|
||||
<div className="font-medium">Shared with:</div>
|
||||
<ul className="list-disc list-inside pl-2">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 dark:text-titanio mb-1">Shared with</div>
|
||||
<ul className="space-y-1">
|
||||
{doc.sharedVehicleIds.map((vehicleId) => {
|
||||
const sharedVehicle = vehiclesMap.get(vehicleId);
|
||||
return (
|
||||
<li key={vehicleId}>
|
||||
<button
|
||||
onClick={() => navigate(`/garage/vehicles/${vehicleId}`)}
|
||||
className="text-blue-600 hover:text-blue-800 underline min-h-[44px] inline-flex items-center"
|
||||
className="text-blue-600 hover:text-blue-800 underline min-h-[44px] inline-flex items-center text-sm"
|
||||
>
|
||||
{getVehicleLabel(sharedVehicle)}
|
||||
</button>
|
||||
@@ -178,26 +280,38 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<DocumentPreview doc={doc} />
|
||||
|
||||
{/* Type-specific Metadata - only show if there's data */}
|
||||
{hasDisplayableMetadata && (
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 dark:text-titanio mb-2">Details</div>
|
||||
<DocumentCardMetadata doc={doc} variant="detail" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button onClick={handleDownload}>Download</Button>
|
||||
<Button onClick={handleUpload}>Upload/Replace</Button>
|
||||
<Button onClick={() => setIsEditOpen(true)} variant="secondary">Edit</Button>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-silverstone">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleDownload} className="min-h-[44px]">Download</Button>
|
||||
<Button onClick={handleUpload} className="min-h-[44px]">Upload/Replace</Button>
|
||||
<Button onClick={() => setIsEditOpen(true)} variant="secondary" className="min-h-[44px]">Edit</Button>
|
||||
</div>
|
||||
{upload.isPending && (
|
||||
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
|
||||
<div className="text-sm text-slate-600 dark:text-titanio mt-2">Uploading... {upload.progress}%</div>
|
||||
)}
|
||||
{upload.isError && (
|
||||
<div className="text-sm text-red-600">
|
||||
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
{((upload.error as any)?.response?.status === 415)
|
||||
? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'
|
||||
: 'Failed to upload file. Please try again.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc && (
|
||||
<EditDocumentDialog
|
||||
open={isEditOpen}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
@@ -135,8 +137,12 @@ export const DocumentsPage: React.FC = () => {
|
||||
return (
|
||||
<Card key={doc.id}>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="font-medium">{doc.title}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{doc.title}</span>
|
||||
<ExpirationBadge expirationDate={doc.expirationDate} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<DocumentCardMetadata doc={doc} variant="card" />
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-500">Vehicle: </span>
|
||||
<button
|
||||
@@ -152,7 +158,7 @@ export const DocumentsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>View Details</Button>
|
||||
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user