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 };
+ }),
});