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
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:
@@ -421,6 +421,165 @@ export class DocumentsController {
|
|||||||
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
||||||
return reply.send(stream);
|
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 {
|
function cryptoRandom(): string {
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.get.bind(ctrl)
|
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', {
|
fastify.post<{ Body: any }>('/documents', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.create.bind(ctrl)
|
handler: ctrl.create.bind(ctrl)
|
||||||
@@ -56,4 +46,20 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.download.bind(ctrl)
|
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)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const ListQuerySchema = z.object({
|
|||||||
|
|
||||||
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
||||||
export const VehicleParamsSchema = z.object({ vehicleId: 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 CreateBodySchema = CreateDocumentBodySchema;
|
||||||
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
||||||
@@ -16,6 +20,7 @@ export const UpdateBodySchema = UpdateDocumentBodySchema;
|
|||||||
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
||||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||||
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
||||||
|
export type DocumentVehicleParams = z.infer<typeof DocumentVehicleParamsSchema>;
|
||||||
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
||||||
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class DocumentsRepository {
|
|||||||
expirationDate: row.expiration_date,
|
expirationDate: row.expiration_date,
|
||||||
emailNotifications: row.email_notifications,
|
emailNotifications: row.email_notifications,
|
||||||
scanForMaintenance: row.scan_for_maintenance,
|
scanForMaintenance: row.scan_for_maintenance,
|
||||||
|
sharedVehicleIds: row.shared_vehicle_ids || [],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
deletedAt: row.deleted_at
|
deletedAt: row.deleted_at
|
||||||
@@ -50,11 +51,12 @@ export class DocumentsRepository {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}): Promise<DocumentRecord> {
|
}): Promise<DocumentRecord> {
|
||||||
const res = await this.db.query(
|
const res = await this.db.query(
|
||||||
`INSERT INTO documents (
|
`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 ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
doc.id,
|
doc.id,
|
||||||
@@ -68,6 +70,7 @@ export class DocumentsRepository {
|
|||||||
doc.expirationDate ?? null,
|
doc.expirationDate ?? null,
|
||||||
doc.emailNotifications ?? false,
|
doc.emailNotifications ?? false,
|
||||||
doc.scanForMaintenance ?? false,
|
doc.scanForMaintenance ?? false,
|
||||||
|
doc.sharedVehicleIds ?? [],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return this.mapDocumentRecord(res.rows[0]);
|
return this.mapDocumentRecord(res.rows[0]);
|
||||||
@@ -103,6 +106,7 @@ export class DocumentsRepository {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}>,
|
}>,
|
||||||
client?: any
|
client?: any
|
||||||
): Promise<DocumentRecord[]> {
|
): Promise<DocumentRecord[]> {
|
||||||
@@ -128,17 +132,18 @@ export class DocumentsRepository {
|
|||||||
doc.issuedDate ?? null,
|
doc.issuedDate ?? null,
|
||||||
doc.expirationDate ?? null,
|
doc.expirationDate ?? null,
|
||||||
doc.emailNotifications ?? false,
|
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);
|
placeholders.push(placeholder);
|
||||||
values.push(...docParams);
|
values.push(...docParams);
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO documents (
|
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(', ')}
|
VALUES ${placeholders.join(', ')}
|
||||||
RETURNING *
|
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]);
|
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 fields: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let i = 1;
|
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.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.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.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);
|
if (!fields.length) return this.findById(id, userId);
|
||||||
params.push(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 *`;
|
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;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ export class DocumentsService {
|
|||||||
|
|
||||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
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 id = randomUUID();
|
||||||
const doc = await this.repo.insert({
|
const doc = await this.repo.insert({
|
||||||
id,
|
id,
|
||||||
@@ -24,6 +38,7 @@ export class DocumentsService {
|
|||||||
expirationDate: body.expirationDate ?? null,
|
expirationDate: body.expirationDate ?? null,
|
||||||
emailNotifications: body.emailNotifications ?? false,
|
emailNotifications: body.emailNotifications ?? false,
|
||||||
scanForMaintenance: body.scanForMaintenance ?? false,
|
scanForMaintenance: body.scanForMaintenance ?? false,
|
||||||
|
sharedVehicleIds: body.sharedVehicleIds ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-create ownership_cost when insurance/registration has cost data
|
// 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) {
|
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
|
||||||
const existing = await this.repo.findById(id, userId);
|
const existing = await this.repo.findById(id, userId);
|
||||||
if (!existing) return null;
|
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') {
|
if (patch && typeof patch === 'object') {
|
||||||
const updated = await this.repo.updateMetadata(id, userId, patch as any);
|
const updated = await this.repo.updateMetadata(id, userId, patch as any);
|
||||||
|
|
||||||
@@ -178,6 +207,94 @@ export class DocumentsService {
|
|||||||
await this.repo.softDelete(id, userId);
|
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) {
|
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]);
|
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||||
if (!res.rows[0]) {
|
if (!res.rows[0]) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface DocumentRecord {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
@@ -38,6 +39,7 @@ export const CreateDocumentBodySchema = z.object({
|
|||||||
expirationDate: z.string().optional(),
|
expirationDate: z.string().optional(),
|
||||||
emailNotifications: z.boolean().optional(),
|
emailNotifications: z.boolean().optional(),
|
||||||
scanForMaintenance: z.boolean().optional(),
|
scanForMaintenance: z.boolean().optional(),
|
||||||
|
sharedVehicleIds: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export const UpdateDocumentBodySchema = z.object({
|
|||||||
expirationDate: z.string().nullable().optional(),
|
expirationDate: z.string().nullable().optional(),
|
||||||
emailNotifications: z.boolean().optional(),
|
emailNotifications: z.boolean().optional(),
|
||||||
scanForMaintenance: z.boolean().optional(),
|
scanForMaintenance: z.boolean().optional(),
|
||||||
|
sharedVehicleIds: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -60,5 +60,16 @@ export const documentsApi = {
|
|||||||
// Return a blob for inline preview / download
|
// Return a blob for inline preview / download
|
||||||
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
|
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
|
||||||
return res.data as 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}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,41 +6,81 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
|||||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCreateDocument } from '../hooks/useDocuments';
|
import { useCreateDocument, useUpdateDocument, useAddSharedVehicle, useRemoveVehicleFromDocument } from '../hooks/useDocuments';
|
||||||
import { documentsApi } from '../api/documents.api';
|
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 { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||||
|
|
||||||
interface DocumentFormProps {
|
interface DocumentFormProps {
|
||||||
|
mode?: 'create' | 'edit';
|
||||||
|
initialValues?: Partial<DocumentRecord>;
|
||||||
|
documentId?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel }) => {
|
export const DocumentForm: React.FC<DocumentFormProps> = ({
|
||||||
const [documentType, setDocumentType] = React.useState<DocumentType>('insurance');
|
mode = 'create',
|
||||||
const [vehicleID, setVehicleID] = React.useState<string>('');
|
initialValues,
|
||||||
const [title, setTitle] = React.useState<string>('');
|
documentId,
|
||||||
const [notes, setNotes] = React.useState<string>('');
|
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
|
// Insurance fields
|
||||||
const [insuranceCompany, setInsuranceCompany] = React.useState<string>('');
|
const [insuranceCompany, setInsuranceCompany] = React.useState<string>(
|
||||||
const [policyNumber, setPolicyNumber] = React.useState<string>('');
|
initialValues?.details?.insuranceCompany || ''
|
||||||
const [effectiveDate, setEffectiveDate] = React.useState<string>('');
|
);
|
||||||
const [expirationDate, setExpirationDate] = React.useState<string>('');
|
const [policyNumber, setPolicyNumber] = React.useState<string>(
|
||||||
const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState<string>('');
|
initialValues?.details?.policyNumber || ''
|
||||||
const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState<string>('');
|
);
|
||||||
const [propertyDamage, setPropertyDamage] = React.useState<string>('');
|
const [effectiveDate, setEffectiveDate] = React.useState<string>(
|
||||||
const [premium, setPremium] = 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
|
// Registration fields
|
||||||
const [licensePlate, setLicensePlate] = React.useState<string>('');
|
const [licensePlate, setLicensePlate] = React.useState<string>(
|
||||||
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>('');
|
initialValues?.details?.licensePlate || ''
|
||||||
const [registrationCost, setRegistrationCost] = React.useState<string>('');
|
);
|
||||||
|
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>(
|
||||||
|
initialValues?.expirationDate || ''
|
||||||
|
);
|
||||||
|
const [registrationCost, setRegistrationCost] = React.useState<string>(
|
||||||
|
initialValues?.details?.cost ? String(initialValues.details.cost) : ''
|
||||||
|
);
|
||||||
|
|
||||||
// Manual fields
|
// 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 [file, setFile] = React.useState<File | null>(null);
|
||||||
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
|
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
|
||||||
@@ -49,6 +89,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
|
|
||||||
const { data: vehicles } = useVehicles();
|
const { data: vehicles } = useVehicles();
|
||||||
const create = useCreateDocument();
|
const create = useCreateDocument();
|
||||||
|
const update = useUpdateDocument(documentId || '');
|
||||||
|
const addSharedVehicle = useAddSharedVehicle();
|
||||||
|
const removeSharedVehicle = useRemoveVehicleFromDocument();
|
||||||
const { hasAccess } = useTierAccess();
|
const { hasAccess } = useTierAccess();
|
||||||
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
|
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
|
||||||
|
|
||||||
@@ -67,6 +110,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
setRegistrationExpirationDate('');
|
setRegistrationExpirationDate('');
|
||||||
setRegistrationCost('');
|
setRegistrationCost('');
|
||||||
setScanForMaintenance(false);
|
setScanForMaintenance(false);
|
||||||
|
setSelectedSharedVehicles([]);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -106,6 +150,61 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
}
|
}
|
||||||
// Manual type: no details or dates, just scanForMaintenance flag
|
// 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({
|
const created = await create.mutateAsync({
|
||||||
vehicleId: vehicleID,
|
vehicleId: vehicleID,
|
||||||
documentType: documentType,
|
documentType: documentType,
|
||||||
@@ -138,12 +237,13 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
if (status === 415) {
|
if (status === 415) {
|
||||||
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
|
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
|
||||||
} else {
|
} else {
|
||||||
setError(err?.message || 'Failed to create document');
|
setError(err?.message || `Failed to ${mode === 'edit' ? 'update' : 'create'} document`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
@@ -159,6 +259,17 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
return v.id.slice(0, 8) + '...';
|
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 (
|
return (
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<form onSubmit={handleSubmit} className="w-full">
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
@@ -170,6 +281,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
value={vehicleID}
|
value={vehicleID}
|
||||||
onChange={(e) => setVehicleID(e.target.value)}
|
onChange={(e) => setVehicleID(e.target.value)}
|
||||||
required
|
required
|
||||||
|
disabled={mode === 'edit'}
|
||||||
>
|
>
|
||||||
<option value="">Select vehicle...</option>
|
<option value="">Select vehicle...</option>
|
||||||
{(vehicles || []).map((v: Vehicle) => (
|
{(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"
|
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}
|
value={documentType}
|
||||||
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
|
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
|
||||||
|
disabled={mode === 'edit'}
|
||||||
>
|
>
|
||||||
<option value="insurance">Insurance</option>
|
<option value="insurance">Insurance</option>
|
||||||
<option value="registration">Registration</option>
|
<option value="registration">Registration</option>
|
||||||
@@ -307,6 +420,32 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
|||||||
onChange={(e) => setPremium(e.target.value)}
|
onChange={(e) => setPremium(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col md:col-span-2">
|
<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">
|
<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
|
<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"
|
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">
|
<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>
|
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Insurance Document',
|
title: 'Insurance Document',
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'registration',
|
documentType: 'registration',
|
||||||
title: 'Registration Photo',
|
title: 'Registration Photo',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
@@ -52,6 +54,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Text Document',
|
title: 'Text Document',
|
||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -50,6 +50,9 @@ export function useCreateDocument() {
|
|||||||
fileHash: null,
|
fileHash: null,
|
||||||
issuedDate: newDocument.issuedDate || null,
|
issuedDate: newDocument.issuedDate || null,
|
||||||
expirationDate: newDocument.expirationDate || null,
|
expirationDate: newDocument.expirationDate || null,
|
||||||
|
emailNotifications: newDocument.emailNotifications,
|
||||||
|
scanForMaintenance: newDocument.scanForMaintenance,
|
||||||
|
sharedVehicleIds: newDocument.sharedVehicleIds || [],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@@ -225,3 +228,123 @@ export function useUploadDocument(id: string) {
|
|||||||
networkMode: 'offlineFirst',
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('DocumentsMobileScreen', () => {
|
|||||||
vehicleId: 'vehicle-1',
|
vehicleId: 'vehicle-1',
|
||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Car Insurance',
|
title: 'Car Insurance',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@ describe('DocumentsMobileScreen', () => {
|
|||||||
vehicleId: 'vehicle-2',
|
vehicleId: 'vehicle-2',
|
||||||
documentType: 'registration',
|
documentType: 'registration',
|
||||||
title: 'Vehicle Registration',
|
title: 'Vehicle Registration',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef, useMemo } from 'react';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -7,6 +7,8 @@ import { useDocumentsList } from '../hooks/useDocuments';
|
|||||||
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
|
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
import { Button } from '../../../shared-minimal/components/Button';
|
||||||
import { AddDocumentDialog } from '../components/AddDocumentDialog';
|
import { AddDocumentDialog } from '../components/AddDocumentDialog';
|
||||||
|
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
|
import { getVehicleLabel } from '../utils/vehicleLabel';
|
||||||
|
|
||||||
export const DocumentsMobileScreen: React.FC = () => {
|
export const DocumentsMobileScreen: React.FC = () => {
|
||||||
console.log('[DocumentsMobileScreen] Component initializing');
|
console.log('[DocumentsMobileScreen] Component initializing');
|
||||||
@@ -17,12 +19,15 @@ export const DocumentsMobileScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Data hooks (unconditional per React rules)
|
// Data hooks (unconditional per React rules)
|
||||||
const { data, isLoading, error } = useDocumentsList();
|
const { data, isLoading, error } = useDocumentsList();
|
||||||
|
const { data: vehicles } = useVehicles();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [currentId, setCurrentId] = React.useState<string | null>(null);
|
const [currentId, setCurrentId] = React.useState<string | null>(null);
|
||||||
const upload = useUploadWithProgress(currentId || '');
|
const upload = useUploadWithProgress(currentId || '');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
|
||||||
|
|
||||||
const triggerUpload = (docId: string) => {
|
const triggerUpload = (docId: string) => {
|
||||||
try {
|
try {
|
||||||
setCurrentId(docId);
|
setCurrentId(docId);
|
||||||
@@ -170,14 +175,25 @@ export const DocumentsMobileScreen: React.FC = () => {
|
|||||||
{!isLoading && !hasError && data && data.length > 0 && (
|
{!isLoading && !hasError && data && data.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.map((doc) => {
|
{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 (
|
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>
|
||||||
<div className="font-medium text-slate-800 dark:text-avus">{doc.title}</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>
|
||||||
<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={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||||
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
|
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
|
||||||
{upload.isPending && currentId === doc.id && (
|
{upload.isPending && currentId === doc.id && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
@@ -8,14 +8,22 @@ import { useDocument } from '../hooks/useDocuments';
|
|||||||
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
|
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
|
||||||
import { documentsApi } from '../api/documents.api';
|
import { documentsApi } from '../api/documents.api';
|
||||||
import { DocumentPreview } from '../components/DocumentPreview';
|
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 = () => {
|
export const DocumentDetailPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
|
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 inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const upload = useUploadWithProgress(id!);
|
const upload = useUploadWithProgress(id!);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
|
||||||
|
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -141,13 +149,42 @@ export const DocumentDetailPage: React.FC = () => {
|
|||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-2">
|
||||||
<h2 className="text-xl font-semibold">{doc.title}</h2>
|
<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">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">
|
<div className="pt-2">
|
||||||
<DocumentPreview doc={doc} />
|
<DocumentPreview doc={doc} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
<Button onClick={handleDownload}>Download</Button>
|
<Button onClick={handleDownload}>Download</Button>
|
||||||
<Button onClick={handleUpload}>Upload/Replace</Button>
|
<Button onClick={handleUpload}>Upload/Replace</Button>
|
||||||
|
<Button onClick={() => setIsEditOpen(true)} variant="secondary">Edit</Button>
|
||||||
</div>
|
</div>
|
||||||
{upload.isPending && (
|
{upload.isPending && (
|
||||||
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
|
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
|
||||||
@@ -161,6 +198,16 @@ export const DocumentDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{doc && (
|
||||||
|
<EditDocumentDialog
|
||||||
|
open={isEditOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditOpen(false);
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
document={doc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments';
|
import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
import { Button } from '../../../shared-minimal/components/Button';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AddDocumentDialog } from '../components/AddDocumentDialog';
|
import { AddDocumentDialog } from '../components/AddDocumentDialog';
|
||||||
|
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
|
import { getVehicleLabel } from '../utils/vehicleLabel';
|
||||||
|
|
||||||
export const DocumentsPage: React.FC = () => {
|
export const DocumentsPage: React.FC = () => {
|
||||||
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
|
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
|
||||||
const { data, isLoading, error } = useDocumentsList();
|
const { data, isLoading, error } = useDocumentsList();
|
||||||
|
const { data: vehicles } = useVehicles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const removeDoc = useDeleteDocument();
|
const removeDoc = useDeleteDocument();
|
||||||
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
|
||||||
|
|
||||||
// Show loading while auth is initializing
|
// Show loading while auth is initializing
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -124,19 +129,36 @@ export const DocumentsPage: React.FC = () => {
|
|||||||
|
|
||||||
{!isLoading && !error && data && data.length > 0 && (
|
{!isLoading && !error && data && data.length > 0 && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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}>
|
<Card key={doc.id}>
|
||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-2">
|
||||||
<div className="font-medium">{doc.title}</div>
|
<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">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">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||||
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
|
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DocumentRecord {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
@@ -33,6 +34,7 @@ export interface CreateDocumentRequest {
|
|||||||
expirationDate?: string;
|
expirationDate?: string;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDocumentRequest {
|
export interface UpdateDocumentRequest {
|
||||||
@@ -43,5 +45,6 @@ export interface UpdateDocumentRequest {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
frontend/src/features/documents/utils/vehicleLabel.ts
Normal file
11
frontend/src/features/documents/utils/vehicleLabel.ts
Normal 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) + '...';
|
||||||
|
};
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { vehiclesApi } from '../api/vehicles.api';
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
@@ -23,6 +24,9 @@ import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
|||||||
// Unit conversions now handled by backend
|
// Unit conversions now handled by backend
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
import { OwnershipCostsList } from '../../ownership-costs';
|
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<{
|
const DetailField: React.FC<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,9 +57,14 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
|
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
|
||||||
|
|
||||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
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 queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
|
||||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||||
// Unit conversions now handled by backend
|
// 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 });
|
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());
|
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
}, [fuelLogs]);
|
}, [fuelLogs, documents]);
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
if (recordFilter === 'All') return records;
|
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;
|
const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null;
|
||||||
setEditingLog(log);
|
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);
|
const handleCloseEdit = () => setEditingLog(null);
|
||||||
@@ -398,29 +461,43 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
<TableCell>Type</TableCell>
|
<TableCell>Type</TableCell>
|
||||||
<TableCell>Summary</TableCell>
|
<TableCell>Summary</TableCell>
|
||||||
<TableCell align="right">Amount</TableCell>
|
<TableCell align="right">Amount</TableCell>
|
||||||
|
<TableCell align="right" sx={{ width: 80 }}>Actions</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isFuelLoading && (
|
{(isFuelLoading || isDocumentsLoading) && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={5}>
|
||||||
<Typography color="text.secondary">Loading records…</Typography>
|
<Typography color="text.secondary">Loading records…</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!isFuelLoading && filteredRecords.length === 0 && (
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={5}>
|
||||||
<Typography color="text.secondary">No records found for this filter.</Typography>
|
<Typography color="text.secondary">No records found for this filter.</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!isFuelLoading && filteredRecords.map((rec) => (
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => (
|
||||||
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
<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>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{rec.type}</TableCell>
|
<TableCell>{rec.type}</TableCell>
|
||||||
<TableCell>{rec.summary}</TableCell>
|
<TableCell>{rec.summary}</TableCell>
|
||||||
<TableCell align="right">{rec.amount || '—'}</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -461,6 +538,15 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Document Confirmation Dialog */}
|
||||||
|
<DeleteDocumentConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={handleDeleteCancel}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
document={documentToDelete}
|
||||||
|
vehicleId={id || null}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user