feat: add type-specific metadata and expiration badges to documents UX (refs #43)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 2m46s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-17 20:29:54 -06:00
parent 2ebae468c6
commit b0e392fef1
8 changed files with 742 additions and 53 deletions

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
@@ -145,59 +147,150 @@ 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" />
<Card>
<div className="p-4 space-y-2">
<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>
<button
onClick={() => navigate(`/garage/vehicles/${doc.vehicleId}`)}
className="text-blue-600 hover:text-blue-800 underline min-h-[44px] inline-flex items-center"
>
{getVehicleLabel(vehicle)}
</button>
</div>
{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">
{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"
>
{getVehicleLabel(sharedVehicle)}
</button>
</li>
);
})}
</ul>
{/* Mobile Layout: Stacked */}
<div className="md:hidden space-y-4">
{/* Header Card - Mobile */}
<Card>
<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>
<ExpirationBadge expirationDate={doc.expirationDate} />
</div>
)}
<div className="pt-2">
<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>
<DocumentCardMetadata doc={doc} variant="mobile" />
</div>
</Card>
{/* Preview Card - Mobile */}
<Card>
<div className="p-4">
<DocumentPreview doc={doc} />
</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>
</div>
{upload.isPending && (
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
)}
{upload.isError && (
<div className="text-sm text-red-600">
{((upload.error as any)?.response?.status === 415)
? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'
: 'Failed to upload file. Please try again.'}
</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>
</Card>
{/* 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"
>
{getVehicleLabel(vehicle)}
</button>
</div>
{/* Shared Vehicles */}
{doc.sharedVehicleIds.length > 0 && (
<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 text-sm"
>
{getVehicleLabel(sharedVehicle)}
</button>
</li>
);
})}
</ul>
</div>
)}
{/* Type-specific Metadata */}
<div>
<div className="text-sm text-slate-500 dark:text-titanio mb-2">Details</div>
<DocumentCardMetadata doc={doc} variant="detail" />
</div>
{/* 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 dark:text-titanio mt-2">Uploading... {upload.progress}%</div>
)}
{upload.isError && (
<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}

View File

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