diff --git a/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx b/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx index 93a3f85..175c72e 100644 --- a/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx +++ b/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx @@ -6,6 +6,7 @@ import type { UseFormReturnType } from "@mantine/form"; import classes from "./vehicle-log-form.module.scss"; interface VehicleLogFormData { + id: number | null; date: string | null; destination: string; departureTime: string | null; diff --git a/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.module.scss b/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.module.scss index 92b090c..f730ee6 100644 --- a/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.module.scss +++ b/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.module.scss @@ -37,3 +37,18 @@ font-size: 14px; font-weight: $font-weight-semibold; } + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.errorContainer { + display: flex; + align-items: flex-start; + width: 100%; + padding: 16px; +} diff --git a/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.tsx b/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.tsx index 24a395c..1bdd5d8 100644 --- a/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.tsx +++ b/src/app/_components/vehiclelogcomponents/vehicle-log-table-view.tsx @@ -1,4 +1,5 @@ "use client"; +import { Alert, Box, Loader } from "@mantine/core"; import type { ColDef, IHeaderParams } from "ag-grid-community"; import { AllCommunityModule, ModuleRegistry, themeQuartz } from "ag-grid-community"; import { AgGridReact } from "ag-grid-react"; @@ -81,8 +82,8 @@ export default function VehicleLogTableView({ onRowClick }: VehicleLogTableViewP // Custom theme for the table const theme = themeQuartz.withParams(TABLE_THEME_PARAMS); - // Fetch vehicle logs from database (bookings + post-trip surveys joined) - const { data: vehicleLogs = [] } = api.vehicleLogs.getAll.useQuery(); + // Fetch vehicle logs from database + const { data: vehicleLogs = [], isLoading, isError } = api.vehicleLogs.getAll.useQuery(); const columnDefs: ColDef[] = useMemo( () => [ @@ -162,14 +163,26 @@ export default function VehicleLogTableView({ onRowClick }: VehicleLogTableViewP return (
- event.data && onRowClick?.(event.data)} - /> + {isLoading ? ( + + + + ) : isError ? ( + + + Failed to load vehicle logs. Please try again later. + + + ) : ( + event.data && onRowClick?.(event.data)} + /> + )}
); } diff --git a/src/app/admin/vehicle-logs/page.tsx b/src/app/admin/vehicle-logs/page.tsx index 6590fdf..8119412 100644 --- a/src/app/admin/vehicle-logs/page.tsx +++ b/src/app/admin/vehicle-logs/page.tsx @@ -11,15 +11,44 @@ import VehicleLogTableView, { } from "@/app/_components/vehiclelogcomponents/vehicle-log-table-view"; import Grid from "@/assets/icons/grid"; import Plus from "@/assets/icons/plus"; +import { useSession } from "@/lib/auth-client"; import { notify } from "@/lib/notifications"; +import { api } from "@/trpc/react"; export default function VehicleLogsPage() { const [showModal, setShowModal] = useState(false); const [isEditMode, setIsEditMode] = useState(false); - const [loading, setLoading] = useState(false); + + const { data: session } = useSession(); + const utils = api.useUtils(); + + const createLog = api.vehicleLogs.create.useMutation({ + onSuccess: () => { + void utils.vehicleLogs.getAll.invalidate(); + setShowModal(false); + notify.success("Vehicle log added successfully"); + form.reset(); + }, + onError: (error) => { + notify.error(error.message ?? "Failed to add vehicle log"); + }, + }); + + const updateLog = api.vehicleLogs.update.useMutation({ + onSuccess: () => { + void utils.vehicleLogs.getAll.invalidate(); + setShowModal(false); + notify.success("Vehicle log updated successfully"); + form.reset(); + }, + onError: (error) => { + notify.error(error.message ?? "Failed to update vehicle log"); + }, + }); const form = useForm({ initialValues: { + id: null as number | null, date: null as string | null, destination: "", departureTime: null as string | null, @@ -67,6 +96,7 @@ export default function VehicleLogsPage() { const handleRowClick = (log: VehicleLogData) => { setIsEditMode(true); form.setValues({ + id: log.ID, date: log.DATE || null, destination: log.DESTINATION, departureTime: log.DEPARTURE_TIME || null, @@ -79,28 +109,51 @@ export default function VehicleLogsPage() { setShowModal(true); }; - const handleConfirm = async () => { - setLoading(true); - + const handleConfirm = () => { const validation = form.validate(); const hasErrors = Object.keys(validation.errors).length > 0; if (hasErrors) { notify.error("Please fix the errors in the form before submitting"); - setLoading(false); return; } - // TODO: Call tRPC mutation to save vehicle log + const values = form.values; + const odometerStart = Math.round(Number.parseFloat(values.odometerStart)); + const odometerEnd = Math.round(Number.parseFloat(values.odometerEnd)); - setTimeout(() => { - setLoading(false); - setShowModal(false); - notify.success( - isEditMode ? "Vehicle log updated successfully" : "Vehicle log added successfully", - ); - form.reset(); - }, 2000); + if (isEditMode && values.id !== null) { + updateLog.mutate({ + id: values.id, + date: values.date ?? undefined, + travelLocation: values.destination || undefined, + departureTime: values.departureTime ?? undefined, + arrivalTime: values.arrivalTime ?? undefined, + odometerStart, + odometerEnd, + driverName: values.driver || undefined, + vehicle: values.vehicle || undefined, + }); + } else { + // validation already guarantees these fields are non-null; + // capture as local consts so TypeScript can narrow the types + if (!values.date || !values.departureTime || !values.arrivalTime) return; + const date = values.date; + const departureTime = values.departureTime; + const arrivalTime = values.arrivalTime; + + createLog.mutate({ + date, + travelLocation: values.destination, + departureTime, + arrivalTime, + odometerStart, + odometerEnd, + driverId: session?.user.id, + driverName: values.driver, + vehicle: values.vehicle, + }); + } }; return ( @@ -133,7 +186,7 @@ export default function VehicleLogsPage() { size="xl" showDefaultFooter confirmText={isEditMode ? "Save Changes" : "Add to Log"} - loading={loading} + loading={createLog.isPending || updateLog.isPending} > diff --git a/src/server/api/routers/vehicle-logs.ts b/src/server/api/routers/vehicle-logs.ts index d63bb9c..a7db52a 100644 --- a/src/server/api/routers/vehicle-logs.ts +++ b/src/server/api/routers/vehicle-logs.ts @@ -1,38 +1,202 @@ -import { desc } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { z } from "zod"; import { logs } from "@/server/db/vehicle-log"; import { adminProcedure, createTRPCRouter } from "../trpc"; export const vehicleLogsRouter = createTRPCRouter({ - // get all vehicle logs from the logs table - getAll: adminProcedure.query(async ({ ctx }) => { - const results = await ctx.db - .select({ - id: logs.id, - date: logs.date, - destination: logs.travelLocation, - departureTime: logs.departureTime, - arrivalTime: logs.arrivalTime, - odometerStart: logs.odometerStart, - odometerEnd: logs.odometerEnd, - kilometersDriven: logs.kilometersDriven, - driverName: logs.driverName, - vehicle: logs.vehicle, - }) - .from(logs) - .orderBy(desc(logs.date)); - - // transform to match frontend interface - return results.map((row) => ({ - ID: row.id, - DATE: row.date || "", - DESTINATION: row.destination || "", - DEPARTURE_TIME: row.departureTime || "", - ARRIVAL_TIME: row.arrivalTime || "", - ODOMETER_START: row.odometerStart || 0, - ODOMETER_END: row.odometerEnd || 0, - KM_DRIVEN: row.kilometersDriven || 0, - DRIVER: row.driverName || "Unknown", - VEHICLE: row.vehicle || "", - })); - }), + // get all vehicle logs, with optional filtering by vehicle, driverName, and date range + getAll: adminProcedure + .input( + z + .object({ + vehicle: z.string().optional(), + driverName: z.string().optional(), + dateFrom: z.string().optional(), // ISO date string "YYYY-MM-DD" + dateTo: z.string().optional(), // ISO date string "YYYY-MM-DD" + }) + .optional(), + ) + .query(async ({ ctx, input }) => { + const filters = []; + + if (input?.vehicle) { + filters.push(eq(logs.vehicle, input.vehicle)); + } + if (input?.driverName) { + filters.push(eq(logs.driverName, input.driverName)); + } + if (input?.dateFrom) { + filters.push(gte(logs.date, input.dateFrom)); + } + if (input?.dateTo) { + filters.push(lte(logs.date, input.dateTo)); + } + + const results = await ctx.db + .select({ + id: logs.id, + date: logs.date, + destination: logs.travelLocation, + departureTime: logs.departureTime, + arrivalTime: logs.arrivalTime, + odometerStart: logs.odometerStart, + odometerEnd: logs.odometerEnd, + kilometersDriven: logs.kilometersDriven, + driverName: logs.driverName, + vehicle: logs.vehicle, + }) + .from(logs) + .where(filters.length > 0 ? and(...filters) : undefined) + .orderBy(desc(logs.date)); + + // transform to match frontend interface + return results.map((row) => ({ + ID: row.id, + DATE: row.date ?? "", + DESTINATION: row.destination ?? "", + DEPARTURE_TIME: row.departureTime ?? "", + ARRIVAL_TIME: row.arrivalTime ?? "", + ODOMETER_START: row.odometerStart ?? 0, + ODOMETER_END: row.odometerEnd ?? 0, + KM_DRIVEN: row.kilometersDriven ?? 0, + DRIVER: row.driverName ?? "Unknown", + VEHICLE: row.vehicle ?? "", + })); + }), + + // create a new vehicle log + create: adminProcedure + .input( + z + .object({ + date: z.string().min(1, "Date is required"), + travelLocation: z.string().min(1, "Destination is required"), + departureTime: z.string().min(1, "Departure time is required"), + arrivalTime: z.string().min(1, "Arrival time is required"), + odometerStart: z.number().int().nonnegative(), + odometerEnd: z.number().int().nonnegative(), + // driverId is optional — falls back to the session user when omitted + driverId: z.string().optional(), + driverName: z.string().min(1, "Driver name is required"), + vehicle: z.string().min(1, "Vehicle is required"), + }) + .refine((data) => data.odometerEnd > data.odometerStart, { + message: "Odometer end must be greater than odometer start", + path: ["odometerEnd"], + }) + .refine((data) => new Date(data.arrivalTime) > new Date(data.departureTime), { + message: "Arrival time must be after departure time", + path: ["arrivalTime"], + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const [row] = await ctx.db + .insert(logs) + .values({ + date: input.date, + travelLocation: input.travelLocation, + departureTime: input.departureTime, + arrivalTime: input.arrivalTime, + odometerStart: input.odometerStart, + odometerEnd: input.odometerEnd, + driverId: input.driverId ?? userId, + driverName: input.driverName, + vehicle: input.vehicle, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + + if (!row) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create vehicle log", + }); + } + + return row; + }), + + // update an existing vehicle log by id + update: adminProcedure + .input( + z + .object({ + id: z.number().int().positive(), + date: z.string().min(1).optional(), + travelLocation: z.string().min(1).optional(), + departureTime: z.string().min(1).optional(), + arrivalTime: z.string().min(1).optional(), + odometerStart: z.number().int().nonnegative().optional(), + odometerEnd: z.number().int().nonnegative().optional(), + driverId: z.string().min(1).optional(), + driverName: z.string().min(1).optional(), + vehicle: z.string().min(1).optional(), + }) + .refine( + (data) => { + if (data.odometerEnd !== undefined && data.odometerStart !== undefined) { + return data.odometerEnd > data.odometerStart; + } + return true; + }, + { + message: "Odometer end must be greater than odometer start", + path: ["odometerEnd"], + }, + ) + .refine( + (data) => { + if (data.arrivalTime !== undefined && data.departureTime !== undefined) { + return new Date(data.arrivalTime) > new Date(data.departureTime); + } + return true; + }, + { + message: "Arrival time must be after departure time", + path: ["arrivalTime"], + }, + ), + ) + .mutation(async ({ ctx, input }) => { + const { id, ...fields } = input; + const userId = ctx.session.user.id; + + const [row] = await ctx.db + .update(logs) + .set({ ...fields, updatedBy: userId }) + .where(eq(logs.id, id)) + .returning(); + + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Vehicle log with id ${id} not found`, + }); + } + + return row; + }), + + // delete a vehicle log by id ("can be created, but seems unlikely to be used") + delete: adminProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + const [row] = await ctx.db + .delete(logs) + .where(eq(logs.id, input.id)) + .returning({ id: logs.id }); + + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Vehicle log with id ${input.id} not found`, + }); + } + + return { success: true, id: row.id }; + }), });