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
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user