Merge pull request 'feat: Document feature enhancements (#31)' (#32) from issue-31-document-enhancements into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m43s
Deploy to Staging / Deploy to Staging (push) Successful in 38s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-01-15 02:35:55 +00:00
20 changed files with 1137 additions and 93 deletions

View File

@@ -421,6 +421,165 @@ export class DocumentsController {
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
return reply.send(stream);
}
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const vehicleId = request.params.vehicleId;
logger.info('Documents by vehicle requested', {
operation: 'documents.listByVehicle',
userId,
vehicleId,
});
try {
const docs = await this.service.getDocumentsByVehicle(userId, vehicleId);
logger.info('Documents by vehicle retrieved', {
operation: 'documents.listByVehicle.success',
userId,
vehicleId,
documentCount: docs.length,
});
return reply.code(200).send(docs);
} catch (e: any) {
if (e.statusCode === 403) {
logger.warn('Vehicle not found or not owned', {
operation: 'documents.listByVehicle.forbidden',
userId,
vehicleId,
});
return reply.code(403).send({ error: 'Forbidden', message: e.message });
}
throw e;
}
}
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const { id: documentId, vehicleId } = request.params;
logger.info('Add vehicle to document requested', {
operation: 'documents.addVehicle',
userId,
documentId,
vehicleId,
});
try {
const updated = await this.service.addVehicleToDocument(userId, documentId, vehicleId);
if (!updated) {
logger.warn('Document not updated (possibly duplicate vehicle)', {
operation: 'documents.addVehicle.not_updated',
userId,
documentId,
vehicleId,
});
return reply.code(400).send({ error: 'Bad Request', message: 'Vehicle could not be added' });
}
logger.info('Vehicle added to document', {
operation: 'documents.addVehicle.success',
userId,
documentId,
vehicleId,
sharedVehicleCount: updated.sharedVehicleIds.length,
});
return reply.code(200).send(updated);
} catch (e: any) {
if (e.statusCode === 404) {
logger.warn('Document not found for adding vehicle', {
operation: 'documents.addVehicle.not_found',
userId,
documentId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: e.message });
}
if (e.statusCode === 400) {
logger.warn('Bad request for adding vehicle', {
operation: 'documents.addVehicle.bad_request',
userId,
documentId,
vehicleId,
reason: e.message,
});
return reply.code(400).send({ error: 'Bad Request', message: e.message });
}
if (e.statusCode === 403) {
logger.warn('Forbidden - vehicle not owned', {
operation: 'documents.addVehicle.forbidden',
userId,
documentId,
vehicleId,
});
return reply.code(403).send({ error: 'Forbidden', message: e.message });
}
throw e;
}
}
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const { id: documentId, vehicleId } = request.params;
logger.info('Remove vehicle from document requested', {
operation: 'documents.removeVehicle',
userId,
documentId,
vehicleId,
});
try {
const updated = await this.service.removeVehicleFromDocument(userId, documentId, vehicleId);
if (!updated) {
// Document was soft deleted
logger.info('Document soft deleted (primary vehicle removed, no shared vehicles)', {
operation: 'documents.removeVehicle.deleted',
userId,
documentId,
vehicleId,
});
return reply.code(204).send();
}
logger.info('Vehicle removed from document', {
operation: 'documents.removeVehicle.success',
userId,
documentId,
vehicleId,
sharedVehicleCount: updated.sharedVehicleIds.length,
primaryVehicleId: updated.vehicleId,
});
return reply.code(200).send(updated);
} catch (e: any) {
if (e.statusCode === 404) {
logger.warn('Document not found for removing vehicle', {
operation: 'documents.removeVehicle.not_found',
userId,
documentId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: e.message });
}
if (e.statusCode === 400) {
logger.warn('Bad request for removing vehicle', {
operation: 'documents.removeVehicle.bad_request',
userId,
documentId,
vehicleId,
reason: e.message,
});
return reply.code(400).send({ error: 'Bad Request', message: e.message });
}
throw e;
}
}
}
function cryptoRandom(): string {

View File

@@ -22,16 +22,6 @@ export const documentsRoutes: FastifyPluginAsync = async (
handler: ctrl.get.bind(ctrl)
});
fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', {
preHandler: [requireAuth],
handler: async (req, reply) => {
const userId = (req as any).user?.sub as string;
const query = { vehicleId: (req.params as any).vehicleId };
const docs = await ctrl['service'].listDocuments(userId, query);
return reply.code(200).send(docs);
}
});
fastify.post<{ Body: any }>('/documents', {
preHandler: [requireAuth],
handler: ctrl.create.bind(ctrl)
@@ -56,4 +46,20 @@ export const documentsRoutes: FastifyPluginAsync = async (
preHandler: [requireAuth],
handler: ctrl.download.bind(ctrl)
});
// Vehicle management routes
fastify.get<{ Params: any }>('/documents/by-vehicle/:vehicleId', {
preHandler: [requireAuth],
handler: ctrl.listByVehicle.bind(ctrl)
});
fastify.post<{ Params: any }>('/documents/:id/vehicles/:vehicleId', {
preHandler: [requireAuth],
handler: ctrl.addVehicle.bind(ctrl)
});
fastify.delete<{ Params: any }>('/documents/:id/vehicles/:vehicleId', {
preHandler: [requireAuth],
handler: ctrl.removeVehicle.bind(ctrl)
});
};

View File

@@ -9,6 +9,10 @@ export const ListQuerySchema = z.object({
export const IdParamsSchema = z.object({ id: z.string().uuid() });
export const VehicleParamsSchema = z.object({ vehicleId: z.string().uuid() });
export const DocumentVehicleParamsSchema = z.object({
id: z.string().uuid(),
vehicleId: z.string().uuid()
});
export const CreateBodySchema = CreateDocumentBodySchema;
export const UpdateBodySchema = UpdateDocumentBodySchema;
@@ -16,6 +20,7 @@ export const UpdateBodySchema = UpdateDocumentBodySchema;
export type ListQuery = z.infer<typeof ListQuerySchema>;
export type IdParams = z.infer<typeof IdParamsSchema>;
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
export type DocumentVehicleParams = z.infer<typeof DocumentVehicleParamsSchema>;
export type CreateBody = z.infer<typeof CreateBodySchema>;
export type UpdateBody = z.infer<typeof UpdateBodySchema>;

View File

@@ -28,6 +28,7 @@ export class DocumentsRepository {
expirationDate: row.expiration_date,
emailNotifications: row.email_notifications,
scanForMaintenance: row.scan_for_maintenance,
sharedVehicleIds: row.shared_vehicle_ids || [],
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at
@@ -50,11 +51,12 @@ export class DocumentsRepository {
expirationDate?: string | null;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}): Promise<DocumentRecord> {
const res = await this.db.query(
`INSERT INTO documents (
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
doc.id,
@@ -68,6 +70,7 @@ export class DocumentsRepository {
doc.expirationDate ?? null,
doc.emailNotifications ?? false,
doc.scanForMaintenance ?? false,
doc.sharedVehicleIds ?? [],
]
);
return this.mapDocumentRecord(res.rows[0]);
@@ -103,6 +106,7 @@ export class DocumentsRepository {
expirationDate?: string | null;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}>,
client?: any
): Promise<DocumentRecord[]> {
@@ -128,17 +132,18 @@ export class DocumentsRepository {
doc.issuedDate ?? null,
doc.expirationDate ?? null,
doc.emailNotifications ?? false,
doc.scanForMaintenance ?? false
doc.scanForMaintenance ?? false,
doc.sharedVehicleIds ?? []
];
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
placeholders.push(placeholder);
values.push(...docParams);
});
const query = `
INSERT INTO documents (
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids
)
VALUES ${placeholders.join(', ')}
RETURNING *
@@ -152,7 +157,7 @@ export class DocumentsRepository {
await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]);
}
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'>>): Promise<DocumentRecord | null> {
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'|'sharedVehicleIds'>>): Promise<DocumentRecord | null> {
const fields: string[] = [];
const params: any[] = [];
let i = 1;
@@ -163,6 +168,7 @@ export class DocumentsRepository {
if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); }
if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); }
if (patch.scanForMaintenance !== undefined) { fields.push(`scan_for_maintenance = $${i++}`); params.push(patch.scanForMaintenance); }
if (patch.sharedVehicleIds !== undefined) { fields.push(`shared_vehicle_ids = $${i++}`); params.push(patch.sharedVehicleIds); }
if (!fields.length) return this.findById(id, userId);
params.push(id, userId);
const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`;
@@ -187,5 +193,56 @@ export class DocumentsRepository {
);
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
// ========================
// Shared Vehicle Operations (Atomic)
// ========================
/**
* Atomically add a vehicle to the shared_vehicle_ids array.
* Uses PostgreSQL array_append() to avoid race conditions.
*/
async addSharedVehicle(docId: string, userId: string, vehicleId: string): Promise<DocumentRecord | null> {
const res = await this.db.query(
`UPDATE documents
SET shared_vehicle_ids = array_append(shared_vehicle_ids, $1::uuid)
WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL
AND NOT ($1::uuid = ANY(shared_vehicle_ids))
RETURNING *`,
[vehicleId, docId, userId]
);
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
/**
* Atomically remove a vehicle from the shared_vehicle_ids array.
* Uses PostgreSQL array_remove() to avoid race conditions.
*/
async removeSharedVehicle(docId: string, userId: string, vehicleId: string): Promise<DocumentRecord | null> {
const res = await this.db.query(
`UPDATE documents
SET shared_vehicle_ids = array_remove(shared_vehicle_ids, $1::uuid)
WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL
RETURNING *`,
[vehicleId, docId, userId]
);
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
/**
* List all documents associated with a vehicle (either as primary or shared).
* Returns documents where vehicle_id = vehicleId OR vehicleId = ANY(shared_vehicle_ids).
*/
async listByVehicle(userId: string, vehicleId: string): Promise<DocumentRecord[]> {
const res = await this.db.query(
`SELECT * FROM documents
WHERE user_id = $1
AND deleted_at IS NULL
AND (vehicle_id = $2 OR $2::uuid = ANY(shared_vehicle_ids))
ORDER BY created_at DESC`,
[userId, vehicleId]
);
return res.rows.map(row => this.mapDocumentRecord(row));
}
}

View File

@@ -11,6 +11,20 @@ export class DocumentsService {
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
await this.assertVehicleOwnership(userId, body.vehicleId);
// Validate shared vehicles if provided (insurance type only)
if (body.sharedVehicleIds && body.sharedVehicleIds.length > 0) {
if (body.documentType !== 'insurance') {
const err: any = new Error('Shared vehicles are only supported for insurance documents');
err.statusCode = 400;
throw err;
}
// Validate ownership of all shared vehicles
for (const vid of body.sharedVehicleIds) {
await this.assertVehicleOwnership(userId, vid);
}
}
const id = randomUUID();
const doc = await this.repo.insert({
id,
@@ -24,6 +38,7 @@ export class DocumentsService {
expirationDate: body.expirationDate ?? null,
emailNotifications: body.emailNotifications ?? false,
scanForMaintenance: body.scanForMaintenance ?? false,
sharedVehicleIds: body.sharedVehicleIds ?? [],
});
// Auto-create ownership_cost when insurance/registration has cost data
@@ -102,6 +117,20 @@ export class DocumentsService {
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
const existing = await this.repo.findById(id, userId);
if (!existing) return null;
// Validate shared vehicles if provided (insurance type only)
if (patch.sharedVehicleIds !== undefined) {
if (existing.documentType !== 'insurance') {
const err: any = new Error('Shared vehicles are only supported for insurance documents');
err.statusCode = 400;
throw err;
}
// Validate ownership of all shared vehicles
for (const vid of patch.sharedVehicleIds) {
await this.assertVehicleOwnership(userId, vid);
}
}
if (patch && typeof patch === 'object') {
const updated = await this.repo.updateMetadata(id, userId, patch as any);
@@ -178,6 +207,94 @@ export class DocumentsService {
await this.repo.softDelete(id, userId);
}
async addVehicleToDocument(userId: string, docId: string, vehicleId: string): Promise<DocumentRecord | null> {
// Validate document exists and is owned by user
const doc = await this.repo.findById(docId, userId);
if (!doc) {
const err: any = new Error('Document not found');
err.statusCode = 404;
throw err;
}
// Only insurance documents support shared vehicles
if (doc.documentType !== 'insurance') {
const err: any = new Error('Shared vehicles are only supported for insurance documents');
err.statusCode = 400;
throw err;
}
// Validate vehicle ownership
await this.assertVehicleOwnership(userId, vehicleId);
// Check if vehicle is already the primary vehicle
if (doc.vehicleId === vehicleId) {
const err: any = new Error('Vehicle is already the primary vehicle for this document');
err.statusCode = 400;
throw err;
}
// Add to shared vehicles (repository handles duplicate check)
return this.repo.addSharedVehicle(docId, userId, vehicleId);
}
async removeVehicleFromDocument(userId: string, docId: string, vehicleId: string): Promise<DocumentRecord | null> {
// Validate document exists and is owned by user
const doc = await this.repo.findById(docId, userId);
if (!doc) {
const err: any = new Error('Document not found');
err.statusCode = 404;
throw err;
}
// Context-aware delete logic
const isSharedVehicle = doc.sharedVehicleIds.includes(vehicleId);
const isPrimaryVehicle = doc.vehicleId === vehicleId;
if (!isSharedVehicle && !isPrimaryVehicle) {
const err: any = new Error('Vehicle is not associated with this document');
err.statusCode = 400;
throw err;
}
// Case 1: Removing from shared vehicles only
if (isSharedVehicle && !isPrimaryVehicle) {
return this.repo.removeSharedVehicle(docId, userId, vehicleId);
}
// Case 2: Removing primary vehicle with no shared vehicles -> soft delete document
if (isPrimaryVehicle && doc.sharedVehicleIds.length === 0) {
await this.repo.softDelete(docId, userId);
return null;
}
// Case 3: Removing primary vehicle with shared vehicles -> promote first shared to primary
if (isPrimaryVehicle && doc.sharedVehicleIds.length > 0) {
const newPrimaryId = doc.sharedVehicleIds[0];
const remainingShared = doc.sharedVehicleIds.slice(1);
// Update primary vehicle and remaining shared vehicles
return this.repo.updateMetadata(docId, userId, {
sharedVehicleIds: remainingShared,
}).then(async () => {
// Update vehicle_id separately as it's not part of the metadata update
const res = await pool.query(
'UPDATE documents SET vehicle_id = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *',
[newPrimaryId, docId, userId]
);
if (!res.rows[0]) return null;
return this.repo.findById(docId, userId);
});
}
return null;
}
async getDocumentsByVehicle(userId: string, vehicleId: string): Promise<DocumentRecord[]> {
// Validate vehicle ownership
await this.assertVehicleOwnership(userId, vehicleId);
return this.repo.listByVehicle(userId, vehicleId);
}
private async assertVehicleOwnership(userId: string, vehicleId: string) {
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
if (!res.rows[0]) {

View File

@@ -22,6 +22,7 @@ export interface DocumentRecord {
expirationDate?: string | null;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds: string[];
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
@@ -38,6 +39,7 @@ export const CreateDocumentBodySchema = z.object({
expirationDate: z.string().optional(),
emailNotifications: z.boolean().optional(),
scanForMaintenance: z.boolean().optional(),
sharedVehicleIds: z.array(z.string().uuid()).optional(),
});
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
@@ -49,6 +51,7 @@ export const UpdateDocumentBodySchema = z.object({
expirationDate: z.string().nullable().optional(),
emailNotifications: z.boolean().optional(),
scanForMaintenance: z.boolean().optional(),
sharedVehicleIds: z.array(z.string().uuid()).optional(),
});
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;

View File

@@ -0,0 +1,18 @@
-- Migration: Add shared_vehicle_ids array column for cross-vehicle document sharing
-- Issue: #31
-- Allows a document to be shared with multiple vehicles beyond its primary vehicle_id
-- Add shared_vehicle_ids column with default empty array
ALTER TABLE documents
ADD COLUMN shared_vehicle_ids UUID[] DEFAULT '{}' NOT NULL;
-- Add GIN index for efficient array membership queries
-- This allows fast lookups of "which documents are shared with vehicle X"
CREATE INDEX idx_documents_shared_vehicle_ids ON documents USING GIN (shared_vehicle_ids array_ops);
-- Example usage:
-- 1. Find all documents shared with a specific vehicle:
-- SELECT * FROM documents WHERE 'vehicle-uuid-here' = ANY(shared_vehicle_ids);
--
-- 2. Find documents by primary OR shared vehicle:
-- SELECT * FROM documents WHERE vehicle_id = 'uuid' OR 'uuid' = ANY(shared_vehicle_ids);

View File

@@ -60,5 +60,16 @@ export const documentsApi = {
// Return a blob for inline preview / download
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
return res.data as Blob;
},
async listByVehicle(vehicleId: string) {
const res = await apiClient.get<DocumentRecord[]>(`/documents/by-vehicle/${vehicleId}`);
return res.data;
},
async addSharedVehicle(docId: string, vehicleId: string) {
const res = await apiClient.post<DocumentRecord>(`/documents/${docId}/vehicles/${vehicleId}`);
return res.data;
},
async removeVehicleFromDocument(docId: string, vehicleId: string) {
await apiClient.delete(`/documents/${docId}/vehicles/${vehicleId}`);
}
};

View File

@@ -0,0 +1,137 @@
/**
* @ai-summary Context-aware document delete confirmation dialog
* Shows different messages based on whether document is being removed from vehicle or fully deleted
*/
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
useMediaQuery,
useTheme,
} from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import type { DocumentRecord } from '../types/documents.types';
export interface DeleteDocumentConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: (fullDelete: boolean) => void;
document: DocumentRecord | null;
vehicleId: string | null;
}
export const DeleteDocumentConfirmDialog: React.FC<DeleteDocumentConfirmDialogProps> = ({
open,
onClose,
onConfirm,
document,
vehicleId,
}) => {
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
if (!document || !vehicleId) {
return null;
}
// Determine delete context
const isPrimaryVehicle = document.vehicleId === vehicleId;
const isSharedVehicle = document.sharedVehicleIds.includes(vehicleId);
const sharedCount = document.sharedVehicleIds.length;
let title: string;
let message: string;
let fullDelete: boolean;
let actionText: string;
if (isPrimaryVehicle && sharedCount === 0) {
// Primary vehicle with no shares: Full delete
title = 'Delete Document?';
message = 'This will permanently delete this document. This action cannot be undone.';
fullDelete = true;
actionText = 'Delete';
} else if (isSharedVehicle) {
// Shared vehicle: Remove association only
title = 'Remove Document from Vehicle?';
message = `This will remove the document from this vehicle. The document will remain shared with ${sharedCount - 1 === 1 ? '1 other vehicle' : `${sharedCount - 1} other vehicles`}.`;
fullDelete = false;
actionText = 'Remove';
} else if (isPrimaryVehicle && sharedCount > 0) {
// Primary vehicle with shares: Full delete (affects all)
title = 'Delete Document?';
message = `This document is shared with ${sharedCount === 1 ? '1 other vehicle' : `${sharedCount} other vehicles`}. Deleting it will remove it from all vehicles. This action cannot be undone.`;
fullDelete = true;
actionText = 'Delete';
} else {
// Fallback case (should not happen)
title = 'Delete Document?';
message = 'This will delete this document. This action cannot be undone.';
fullDelete = true;
actionText = 'Delete';
}
const handleConfirm = () => {
onConfirm(fullDelete);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
fullScreen={isSmallScreen}
PaperProps={{
sx: {
borderRadius: isSmallScreen ? 0 : 2,
},
}}
>
<DialogTitle sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon color="warning" />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
{title}
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ color: 'text.secondary', mt: 1 }}>
{message}
</Typography>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{document.title}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{document.documentType.charAt(0).toUpperCase() + document.documentType.slice(1)}
{document.expirationDate && ` • Expires: ${new Date(document.expirationDate).toLocaleDateString()}`}
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={onClose}
variant="outlined"
sx={{ minWidth: 100, minHeight: 44 }}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="contained"
color={fullDelete ? 'error' : 'primary'}
sx={{ minWidth: 100, minHeight: 44 }}
>
{actionText}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -6,41 +6,81 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import dayjs from 'dayjs';
import { useCreateDocument } from '../hooks/useDocuments';
import { useCreateDocument, useUpdateDocument, useAddSharedVehicle, useRemoveVehicleFromDocument } from '../hooks/useDocuments';
import { documentsApi } from '../api/documents.api';
import type { DocumentType } from '../types/documents.types';
import type { DocumentType, DocumentRecord } from '../types/documents.types';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
interface DocumentFormProps {
mode?: 'create' | 'edit';
initialValues?: Partial<DocumentRecord>;
documentId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel }) => {
const [documentType, setDocumentType] = React.useState<DocumentType>('insurance');
const [vehicleID, setVehicleID] = React.useState<string>('');
const [title, setTitle] = React.useState<string>('');
const [notes, setNotes] = React.useState<string>('');
export const DocumentForm: React.FC<DocumentFormProps> = ({
mode = 'create',
initialValues,
documentId,
onSuccess,
onCancel
}) => {
const [documentType, setDocumentType] = React.useState<DocumentType>(
initialValues?.documentType || 'insurance'
);
const [vehicleID, setVehicleID] = React.useState<string>(initialValues?.vehicleId || '');
const [title, setTitle] = React.useState<string>(initialValues?.title || '');
const [notes, setNotes] = React.useState<string>(initialValues?.notes || '');
// Insurance fields
const [insuranceCompany, setInsuranceCompany] = React.useState<string>('');
const [policyNumber, setPolicyNumber] = React.useState<string>('');
const [effectiveDate, setEffectiveDate] = React.useState<string>('');
const [expirationDate, setExpirationDate] = React.useState<string>('');
const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState<string>('');
const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState<string>('');
const [propertyDamage, setPropertyDamage] = React.useState<string>('');
const [premium, setPremium] = React.useState<string>('');
const [insuranceCompany, setInsuranceCompany] = React.useState<string>(
initialValues?.details?.insuranceCompany || ''
);
const [policyNumber, setPolicyNumber] = React.useState<string>(
initialValues?.details?.policyNumber || ''
);
const [effectiveDate, setEffectiveDate] = React.useState<string>(
initialValues?.issuedDate || ''
);
const [expirationDate, setExpirationDate] = React.useState<string>(
initialValues?.expirationDate || ''
);
const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState<string>(
initialValues?.details?.bodilyInjuryPerson || ''
);
const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState<string>(
initialValues?.details?.bodilyInjuryIncident || ''
);
const [propertyDamage, setPropertyDamage] = React.useState<string>(
initialValues?.details?.propertyDamage || ''
);
const [premium, setPremium] = React.useState<string>(
initialValues?.details?.premium ? String(initialValues.details.premium) : ''
);
// Registration fields
const [licensePlate, setLicensePlate] = React.useState<string>('');
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>('');
const [registrationCost, setRegistrationCost] = React.useState<string>('');
const [licensePlate, setLicensePlate] = React.useState<string>(
initialValues?.details?.licensePlate || ''
);
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>(
initialValues?.expirationDate || ''
);
const [registrationCost, setRegistrationCost] = React.useState<string>(
initialValues?.details?.cost ? String(initialValues.details.cost) : ''
);
// Manual fields
const [scanForMaintenance, setScanForMaintenance] = React.useState<boolean>(false);
const [scanForMaintenance, setScanForMaintenance] = React.useState<boolean>(
initialValues?.scanForMaintenance || false
);
// Shared vehicles for edit mode
const [selectedSharedVehicles, setSelectedSharedVehicles] = React.useState<string[]>(
initialValues?.sharedVehicleIds || []
);
const [file, setFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
@@ -49,6 +89,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
const { data: vehicles } = useVehicles();
const create = useCreateDocument();
const update = useUpdateDocument(documentId || '');
const addSharedVehicle = useAddSharedVehicle();
const removeSharedVehicle = useRemoveVehicleFromDocument();
const { hasAccess } = useTierAccess();
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
@@ -67,6 +110,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
setRegistrationExpirationDate('');
setRegistrationCost('');
setScanForMaintenance(false);
setSelectedSharedVehicles([]);
setFile(null);
setUploadProgress(0);
setError(null);
@@ -106,6 +150,61 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
}
// Manual type: no details or dates, just scanForMaintenance flag
if (mode === 'edit' && documentId) {
// Update existing document
await update.mutateAsync({
title: title.trim(),
notes: notes.trim() || null,
details: Object.keys(details).length > 0 ? details : undefined,
issuedDate: issued_date || null,
expirationDate: expiration_date || null,
scanForMaintenance: documentType === 'manual' ? scanForMaintenance : undefined,
});
// Handle shared vehicles only for insurance documents
if (documentType === 'insurance') {
const currentSharedVehicleIds = initialValues?.sharedVehicleIds || [];
// Add new shared vehicles
const vehiclesToAdd = selectedSharedVehicles.filter(
id => !currentSharedVehicleIds.includes(id)
);
for (const vehicleId of vehiclesToAdd) {
await addSharedVehicle.mutateAsync({ docId: documentId, vehicleId });
}
// Remove unselected shared vehicles
const vehiclesToRemove = currentSharedVehicleIds.filter(
id => !selectedSharedVehicles.includes(id)
);
for (const vehicleId of vehiclesToRemove) {
await removeSharedVehicle.mutateAsync({ docId: documentId, vehicleId });
}
}
// Handle file upload if a new file was selected
if (file) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
try {
await documentsApi.uploadWithProgress(documentId, file, (pct) => setUploadProgress(pct));
} catch (uploadErr: any) {
const status = uploadErr?.response?.status;
if (status === 415) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
setError(uploadErr?.message || 'Failed to upload file');
return;
}
}
onSuccess?.();
} else {
// Create new document
const created = await create.mutateAsync({
vehicleId: vehicleID,
documentType: documentType,
@@ -138,12 +237,13 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
resetForm();
onSuccess?.();
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 415) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
} else {
setError(err?.message || 'Failed to create document');
setError(err?.message || `Failed to ${mode === 'edit' ? 'update' : 'create'} document`);
}
} finally {
setUploadProgress(0);
@@ -159,6 +259,17 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
return v.id.slice(0, 8) + '...';
};
// Filter out the primary vehicle from shared vehicle options
const sharedVehicleOptions = (vehicles || []).filter(v => v.id !== vehicleID);
const handleSharedVehicleToggle = (vehicleId: string) => {
setSelectedSharedVehicles(prev =>
prev.includes(vehicleId)
? prev.filter(id => id !== vehicleId)
: [...prev, vehicleId]
);
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<form onSubmit={handleSubmit} className="w-full">
@@ -170,6 +281,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
value={vehicleID}
onChange={(e) => setVehicleID(e.target.value)}
required
disabled={mode === 'edit'}
>
<option value="">Select vehicle...</option>
{(vehicles || []).map((v: Vehicle) => (
@@ -184,6 +296,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={documentType}
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
disabled={mode === 'edit'}
>
<option value="insurance">Insurance</option>
<option value="registration">Registration</option>
@@ -307,6 +420,32 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
onChange={(e) => setPremium(e.target.value)}
/>
</div>
{mode === 'edit' && sharedVehicleOptions.length > 0 && (
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-2">
Share with other vehicles
</label>
<div className="space-y-2 p-3 border border-slate-300 dark:border-silverstone rounded-lg bg-slate-50 dark:bg-scuro/50 max-h-40 overflow-y-auto">
{sharedVehicleOptions.map((v) => (
<label
key={v.id}
className="flex items-center cursor-pointer min-h-[44px] py-2 px-2 hover:bg-slate-100 dark:hover:bg-scuro rounded"
>
<input
type="checkbox"
checked={selectedSharedVehicles.includes(v.id)}
onChange={() => handleSharedVehicleToggle(v.id)}
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi"
/>
<span className="ml-3 text-sm text-slate-700 dark:text-avus">
{vehicleLabel(v)}
</span>
</label>
))}
</div>
</div>
)}
</>
)}
@@ -393,7 +532,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Upload image/PDF</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">
{mode === 'edit' ? 'Upload new image/PDF (optional)' : 'Upload image/PDF'}
</label>
<div className="flex items-center h-11 min-h-[44px] rounded-lg border px-3 bg-white border-slate-300 focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 dark:bg-scuro dark:border-silverstone dark:focus-within:ring-abudhabi dark:focus-within:border-abudhabi">
<input
className="flex-1 text-gray-900 dark:text-avus file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-500/10 file:text-primary-600 dark:file:bg-abudhabi/20 dark:file:text-abudhabi file:cursor-pointer cursor-pointer"
@@ -413,7 +554,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
)}
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<Button type="submit" className="min-h-[44px]">Create Document</Button>
<Button type="submit" className="min-h-[44px]">
{mode === 'edit' ? 'Save Changes' : 'Create Document'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
</div>

View File

@@ -30,6 +30,7 @@ describe('DocumentPreview', () => {
documentType: 'insurance',
title: 'Insurance Document',
contentType: 'application/pdf',
sharedVehicleIds: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
@@ -41,6 +42,7 @@ describe('DocumentPreview', () => {
documentType: 'registration',
title: 'Registration Photo',
contentType: 'image/jpeg',
sharedVehicleIds: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
@@ -52,6 +54,7 @@ describe('DocumentPreview', () => {
documentType: 'insurance',
title: 'Text Document',
contentType: 'text/plain',
sharedVehicleIds: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};

View File

@@ -0,0 +1,75 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
IconButton,
useMediaQuery,
useTheme,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { DocumentForm } from './DocumentForm';
import type { DocumentRecord } from '../types/documents.types';
interface EditDocumentDialogProps {
open: boolean;
onClose: () => void;
document: DocumentRecord;
}
export const EditDocumentDialog: React.FC<EditDocumentDialogProps> = ({
open,
onClose,
document,
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
return (
<Dialog
open={open}
onClose={onClose}
fullScreen={isSmall}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
{isSmall && (
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
zIndex: 1,
}}
>
<CloseIcon />
</IconButton>
)}
<DialogTitle sx={{ pb: 1 }}>Edit Document</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<DocumentForm
mode="edit"
documentId={document.id}
initialValues={document}
onSuccess={() => {
onClose();
}}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
};
export default EditDocumentDialog;

View File

@@ -50,6 +50,9 @@ export function useCreateDocument() {
fileHash: null,
issuedDate: newDocument.issuedDate || null,
expirationDate: newDocument.expirationDate || null,
emailNotifications: newDocument.emailNotifications,
scanForMaintenance: newDocument.scanForMaintenance,
sharedVehicleIds: newDocument.sharedVehicleIds || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -225,3 +228,123 @@ export function useUploadDocument(id: string) {
networkMode: 'offlineFirst',
});
}
export function useDocumentsByVehicle(vehicleId?: string) {
const query = useQuery({
queryKey: ['documents-by-vehicle', vehicleId],
queryFn: () => documentsApi.listByVehicle(vehicleId!),
enabled: !!vehicleId,
networkMode: 'offlineFirst',
});
return query;
}
export function useAddSharedVehicle() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) =>
documentsApi.addSharedVehicle(docId, vehicleId),
onMutate: async ({ docId, vehicleId }) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', docId] });
await qc.cancelQueries({ queryKey: ['documents'] });
await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', docId]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update individual document
qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => {
if (!old) return old;
return {
...old,
sharedVehicleIds: [...old.sharedVehicleIds, vehicleId],
updatedAt: new Date().toISOString(),
};
});
// Optimistically update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === docId
? { ...doc, sharedVehicleIds: [...doc.sharedVehicleIds, vehicleId], updatedAt: new Date().toISOString() }
: doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, { docId }, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', docId], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] });
},
networkMode: 'offlineFirst',
});
}
export function useRemoveVehicleFromDocument() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) =>
documentsApi.removeVehicleFromDocument(docId, vehicleId),
onMutate: async ({ docId, vehicleId }) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', docId] });
await qc.cancelQueries({ queryKey: ['documents'] });
await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', docId]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update individual document
qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => {
if (!old) return old;
return {
...old,
sharedVehicleIds: old.sharedVehicleIds.filter(id => id !== vehicleId),
updatedAt: new Date().toISOString(),
};
});
// Optimistically update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === docId
? { ...doc, sharedVehicleIds: doc.sharedVehicleIds.filter(id => id !== vehicleId), updatedAt: new Date().toISOString() }
: doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, { docId }, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', docId], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] });
},
networkMode: 'offlineFirst',
});
}

View File

@@ -31,6 +31,7 @@ describe('DocumentsMobileScreen', () => {
vehicleId: 'vehicle-1',
documentType: 'insurance',
title: 'Car Insurance',
sharedVehicleIds: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
@@ -40,6 +41,7 @@ describe('DocumentsMobileScreen', () => {
vehicleId: 'vehicle-2',
documentType: 'registration',
title: 'Vehicle Registration',
sharedVehicleIds: [],
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useMemo } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
import { useNavigate } from 'react-router-dom';
@@ -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 { useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '../utils/vehicleLabel';
export const DocumentsMobileScreen: React.FC = () => {
console.log('[DocumentsMobileScreen] Component initializing');
@@ -17,12 +19,15 @@ export const DocumentsMobileScreen: React.FC = () => {
// Data hooks (unconditional per React rules)
const { data, isLoading, error } = useDocumentsList();
const { data: vehicles } = useVehicles();
const inputRef = useRef<HTMLInputElement | null>(null);
const [currentId, setCurrentId] = React.useState<string | null>(null);
const upload = useUploadWithProgress(currentId || '');
const navigate = useNavigate();
const [isAddOpen, setIsAddOpen] = React.useState(false);
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const triggerUpload = (docId: string) => {
try {
setCurrentId(docId);
@@ -170,14 +175,25 @@ export const DocumentsMobileScreen: React.FC = () => {
{!isLoading && !hasError && data && data.length > 0 && (
<div className="space-y-3">
{data.map((doc) => {
const vehicleLabel = doc.vehicleId ? `${doc.vehicleId.slice(0, 8)}...` : '—';
const vehicle = vehiclesMap.get(doc.vehicleId);
const vehicleLabel = getVehicleLabel(vehicle);
const isShared = doc.sharedVehicleIds.length > 0;
return (
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
<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="text-xs text-slate-500 dark:text-titanio">{doc.documentType} {vehicleLabel}</div>
<div className="text-xs text-slate-500 dark:text-titanio">
{doc.documentType}
{isShared && ' • Shared'}
</div>
<div className="flex gap-2 items-center">
<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"
>
{vehicleLabel}
</button>
</div>
<div className="flex gap-2 items-center flex-wrap">
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
{upload.isPending && currentId === doc.id && (

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
@@ -8,14 +8,22 @@ import { useDocument } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { documentsApi } from '../api/documents.api';
import { DocumentPreview } from '../components/DocumentPreview';
import { EditDocumentDialog } from '../components/EditDocumentDialog';
import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '../utils/vehicleLabel';
export const DocumentDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data: doc, isLoading, error } = useDocument(id);
const { data: doc, isLoading, error, refetch } = useDocument(id);
const { data: vehicle } = useVehicle(doc?.vehicleId || '');
const { data: vehicles } = useVehicles();
const inputRef = useRef<HTMLInputElement | null>(null);
const upload = useUploadWithProgress(id!);
const [isEditOpen, setIsEditOpen] = useState(false);
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const handleDownload = async () => {
if (!id) return;
@@ -141,13 +149,42 @@ export const DocumentDetailPage: React.FC = () => {
<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 text-slate-500">Vehicle: {doc.vehicleId}</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>
</div>
)}
<div className="pt-2">
<DocumentPreview doc={doc} />
</div>
<div className="flex gap-2 pt-2">
<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>
@@ -161,6 +198,16 @@ export const DocumentDetailPage: React.FC = () => {
)}
</div>
</Card>
{doc && (
<EditDocumentDialog
open={isEditOpen}
onClose={() => {
setIsEditOpen(false);
refetch();
}}
document={doc}
/>
)}
</div>
);
};

View File

@@ -1,18 +1,23 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments';
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 { useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '../utils/vehicleLabel';
export const DocumentsPage: React.FC = () => {
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data, isLoading, error } = useDocumentsList();
const { data: vehicles } = useVehicles();
const navigate = useNavigate();
const removeDoc = useDeleteDocument();
const [isAddOpen, setIsAddOpen] = React.useState(false);
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
// Show loading while auth is initializing
if (authLoading) {
return (
@@ -124,19 +129,36 @@ export const DocumentsPage: React.FC = () => {
{!isLoading && !error && data && data.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.map((doc) => (
{data.map((doc) => {
const vehicle = vehiclesMap.get(doc.vehicleId);
const vehicleLabel = getVehicleLabel(vehicle);
return (
<Card key={doc.id}>
<div className="p-4 space-y-2">
<div className="font-medium">{doc.title}</div>
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
<div className="text-sm text-slate-500">Vehicle: {doc.vehicleId}</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"
>
{vehicleLabel}
</button>
</div>
{doc.sharedVehicleIds.length > 0 && (
<div className="text-xs text-slate-500">
Shared with {doc.sharedVehicleIds.length} other vehicle{doc.sharedVehicleIds.length > 1 ? 's' : ''}
</div>
)}
<div className="flex gap-2 pt-2">
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
</div>
</div>
</Card>
))}
);
})}
</div>
)}
</div>

View File

@@ -18,6 +18,7 @@ export interface DocumentRecord {
expirationDate?: string | null;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds: string[];
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
@@ -33,6 +34,7 @@ export interface CreateDocumentRequest {
expirationDate?: string;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}
export interface UpdateDocumentRequest {
@@ -43,5 +45,6 @@ export interface UpdateDocumentRequest {
expirationDate?: string | null;
emailNotifications?: boolean;
scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}

View File

@@ -0,0 +1,11 @@
import type { Vehicle } from '../../vehicles/types/vehicles.types';
export const getVehicleLabel = (vehicle: Vehicle | undefined): string => {
if (!vehicle) return 'Unknown Vehicle';
if (vehicle.nickname?.trim()) return vehicle.nickname.trim();
const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean);
const primary = parts.join(' ').trim();
if (primary.length > 0) return primary;
if (vehicle.vin?.length > 0) return vehicle.vin;
return vehicle.id.slice(0, 8) + '...';
};

View File

@@ -4,12 +4,13 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material';
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import BuildIcon from '@mui/icons-material/Build';
import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card';
@@ -23,6 +24,9 @@ import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
// Unit conversions now handled by backend
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { OwnershipCostsList } from '../../ownership-costs';
import { useDocumentsByVehicle, useDeleteDocument, useRemoveVehicleFromDocument } from '../../documents/hooks/useDocuments';
import { DeleteDocumentConfirmDialog } from '../../documents/components/DeleteDocumentConfirmDialog';
import type { DocumentRecord } from '../../documents/types/documents.types';
const DetailField: React.FC<{
label: string;
@@ -53,9 +57,14 @@ export const VehicleDetailPage: React.FC = () => {
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id);
const { mutateAsync: deleteDocument } = useDeleteDocument();
const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument();
const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
const isSmallScreen = useMediaQuery('(max-width:600px)');
// Unit conversions now handled by backend
@@ -105,8 +114,24 @@ export const VehicleDetailPage: React.FC = () => {
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
}
}
// Add documents to records
if (documents && Array.isArray(documents)) {
for (const doc of documents) {
const parts: string[] = [];
parts.push(doc.title);
parts.push(doc.documentType.charAt(0).toUpperCase() + doc.documentType.slice(1));
if (doc.expirationDate) {
parts.push(`Expires: ${new Date(doc.expirationDate).toLocaleDateString()}`);
}
const summary = parts.join(' • ');
const date = doc.issuedDate || doc.createdAt;
list.push({ id: doc.id, type: 'Documents', date, summary, amount: undefined });
}
}
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [fuelLogs]);
}, [fuelLogs, documents]);
const filteredRecords = useMemo(() => {
if (recordFilter === 'All') return records;
@@ -208,6 +233,44 @@ export const VehicleDetailPage: React.FC = () => {
const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null;
setEditingLog(log);
}
// Documents are handled via delete button, not row click
};
const handleDeleteDocumentClick = (docId: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent row click
const doc = documents?.find(d => d.id === docId) || null;
if (doc) {
setDocumentToDelete(doc);
setDeleteDialogOpen(true);
}
};
const handleDeleteConfirm = async (fullDelete: boolean) => {
if (!documentToDelete || !id) return;
try {
if (fullDelete) {
// Full delete
await deleteDocument(documentToDelete.id);
} else {
// Remove vehicle association only
await removeVehicleFromDocument({ docId: documentToDelete.id, vehicleId: id });
}
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['documents-by-vehicle', id] });
queryClient.invalidateQueries({ queryKey: ['documents'] });
setDeleteDialogOpen(false);
setDocumentToDelete(null);
} catch (err) {
console.error('Error deleting/removing document:', err);
}
};
const handleDeleteCancel = () => {
setDeleteDialogOpen(false);
setDocumentToDelete(null);
};
const handleCloseEdit = () => setEditingLog(null);
@@ -398,29 +461,43 @@ export const VehicleDetailPage: React.FC = () => {
<TableCell>Type</TableCell>
<TableCell>Summary</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="right" sx={{ width: 80 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isFuelLoading && (
{(isFuelLoading || isDocumentsLoading) && (
<TableRow>
<TableCell colSpan={4}>
<TableCell colSpan={5}>
<Typography color="text.secondary">Loading records</Typography>
</TableCell>
</TableRow>
)}
{!isFuelLoading && filteredRecords.length === 0 && (
{!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
<TableCell colSpan={5}>
<Typography color="text.secondary">No records found for this filter.</Typography>
</TableCell>
</TableRow>
)}
{!isFuelLoading && filteredRecords.map((rec) => (
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => (
<TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
<TableCell>{rec.type}</TableCell>
<TableCell>{rec.summary}</TableCell>
<TableCell align="right">{rec.amount || '—'}</TableCell>
<TableCell align="right">
{rec.type === 'Documents' && (
<IconButton
size="small"
color="error"
onClick={(e) => handleDeleteDocumentClick(rec.id, e)}
sx={{ minWidth: 44, minHeight: 44 }}
aria-label="Delete document"
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -461,6 +538,15 @@ export const VehicleDetailPage: React.FC = () => {
</Box>
</DialogContent>
</Dialog>
{/* Delete Document Confirmation Dialog */}
<DeleteDocumentConfirmDialog
open={deleteDialogOpen}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
document={documentToDelete}
vehicleId={id || null}
/>
</Card>
</Box>
);