-
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 92b090c0..f730ee62 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 24a395cf..1bdd5d86 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 6590fdff..8119412c 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/app/admin/vehicles/page.tsx b/src/app/admin/vehicles/page.tsx
new file mode 100644
index 00000000..e1cf2e57
--- /dev/null
+++ b/src/app/admin/vehicles/page.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import {
+ Badge,
+ Box,
+ Center,
+ Group,
+ Loader,
+ ScrollArea,
+ Select,
+ Stack,
+ Table,
+ Text,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { useState } from "react";
+import Button from "@/app/_components/common/button/Button";
+import Modal from "@/app/_components/common/modal/modal";
+import Plus from "@/assets/icons/plus";
+import { notify } from "@/lib/notifications";
+import { api } from "@/trpc/react";
+
+type VehicleRow = {
+ id: string;
+ name: string;
+ active: boolean;
+};
+
+export default function VehiclesPage() {
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [selectedVehicle, setSelectedVehicle] = useState
(null);
+ const utils = api.useUtils();
+
+ const { data: vehiclesList, isLoading: isLoadingVehicles } = api.vehicles.getAllAdmin.useQuery();
+
+ const createVehicle = api.vehicles.create.useMutation({
+ onSuccess: () => {
+ void utils.vehicles.getAll.invalidate();
+ void utils.vehicles.getAllAdmin.invalidate();
+ setShowAddModal(false);
+ notify.success("Vehicle added successfully");
+ addForm.reset();
+ },
+ onError: (error) => {
+ notify.error(error.message ?? "Failed to add vehicle");
+ },
+ });
+
+ const updateVehicle = api.vehicles.update.useMutation({
+ onSuccess: () => {
+ void utils.vehicles.getAll.invalidate();
+ void utils.vehicles.getAllAdmin.invalidate();
+ setShowEditModal(false);
+ setSelectedVehicle(null);
+ notify.success("Vehicle updated successfully");
+ editForm.reset();
+ },
+ onError: (error) => {
+ notify.error(error.message ?? "Failed to update vehicle");
+ },
+ });
+
+ const addForm = useForm({
+ initialValues: {
+ name: "",
+ },
+ validate: {
+ name: (value) => (value.trim().length > 0 ? null : "Vehicle name is required"),
+ },
+ });
+
+ const editForm = useForm({
+ initialValues: {
+ name: "",
+ active: "active",
+ },
+ validate: {
+ name: (value) => (value.trim().length > 0 ? null : "Vehicle name is required"),
+ active: (value) => (value === "active" || value === "inactive" ? null : "Status is required"),
+ },
+ });
+
+ const handleAddVehicle = () => {
+ addForm.reset();
+ setShowAddModal(true);
+ };
+
+ const handleEditVehicle = (vehicle: VehicleRow) => {
+ setSelectedVehicle(vehicle);
+ editForm.setValues({
+ name: vehicle.name,
+ active: vehicle.active ? "active" : "inactive",
+ });
+ setShowEditModal(true);
+ };
+
+ const handleAddConfirm = async () => {
+ const validation = addForm.validate();
+ if (validation.hasErrors) {
+ return;
+ }
+
+ const values = addForm.values;
+ await createVehicle.mutateAsync({
+ name: values.name,
+ });
+ };
+
+ const handleEditConfirm = async () => {
+ if (!selectedVehicle) {
+ return;
+ }
+
+ const validation = editForm.validate();
+ if (validation.hasErrors) {
+ return;
+ }
+
+ const values = editForm.values;
+ await updateVehicle.mutateAsync({
+ id: selectedVehicle.id,
+ name: values.name,
+ active: values.active === "active",
+ });
+ };
+
+ return (
+
+ View Vehicles
+ These vehicles power the select menus in driver and admin forms.
+
+
+ } onClick={handleAddVehicle} />
+
+
+ {isLoadingVehicles ? (
+
+
+
+ ) : vehiclesList && vehiclesList.length > 0 ? (
+
+
+
+
+ Vehicle Name
+ Status
+ Actions
+
+
+
+ {vehiclesList.map((vehicle) => (
+
+ {vehicle.name}
+
+
+ {vehicle.active ? "Active" : "Inactive"}
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ No vehicles found. Add your first vehicle to get started.
+
+ )}
+
+ {
+ addForm.clearErrors();
+ setShowAddModal(false);
+ }}
+ onConfirm={handleAddConfirm}
+ title={
+
+ Add New Vehicle
+
+ }
+ size="md"
+ showDefaultFooter
+ confirmText="Add Vehicle"
+ loading={createVehicle.isPending || updateVehicle.isPending}
+ >
+
+
+
+
+
+ {
+ editForm.clearErrors();
+ setShowEditModal(false);
+ setSelectedVehicle(null);
+ }}
+ onConfirm={handleEditConfirm}
+ title={
+
+ Edit Vehicle
+
+ }
+ size="md"
+ showDefaultFooter
+ confirmText="Save Changes"
+ loading={createVehicle.isPending || updateVehicle.isPending}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/hooks/useVehicles.ts b/src/app/hooks/useVehicles.ts
new file mode 100644
index 00000000..98bc796c
--- /dev/null
+++ b/src/app/hooks/useVehicles.ts
@@ -0,0 +1,21 @@
+import { api } from "@/trpc/react";
+
+/**
+ * Hook to fetch vehicles from the database.
+ * @returns Array of vehicle options in { value, label } format
+ */
+export function useVehicles() {
+ const { data: dbVehicles, isLoading } = api.vehicles.getAll.useQuery(undefined, {
+ retry: 1,
+ });
+
+ return {
+ vehicleOptions:
+ dbVehicles?.map((vehicle) => ({
+ value: vehicle.name,
+ label: vehicle.name,
+ })) ?? [],
+ isLoading,
+ hasDbVehicles: (dbVehicles?.length ?? 0) > 0,
+ };
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 9b6d88f7..31da23b2 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -4,6 +4,7 @@ import { organizationRouter } from "@/server/api/routers/organizations";
import { surveysRouter } from "@/server/api/routers/surveys";
import { tripRouter } from "@/server/api/routers/trip";
import { vehicleLogsRouter } from "@/server/api/routers/vehicle-logs";
+import { vehiclesRouter } from "@/server/api/routers/vehicles";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
/**
@@ -18,6 +19,7 @@ export const appRouter = createTRPCRouter({
bookings: bookingsRouter,
surveys: surveysRouter,
vehicleLogs: vehicleLogsRouter,
+ vehicles: vehiclesRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/surveys.ts b/src/server/api/routers/surveys.ts
index ad644729..79a0dc11 100644
--- a/src/server/api/routers/surveys.ts
+++ b/src/server/api/routers/surveys.ts
@@ -4,8 +4,10 @@ import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { and, desc, eq, gte, lt, or } from "drizzle-orm";
import { z } from "zod";
+import { user } from "@/server/db/auth-schema";
import { bookings } from "@/server/db/booking-schema";
import { postTripSurveys, type SurveyInsertType } from "@/server/db/post-trip-schema";
+import { logs } from "@/server/db/vehicle-log";
import { BookingStatus } from "@/types/types";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -30,6 +32,7 @@ export const surveysRouter = createTRPCRouter({
timeOfDeparture: z.string().datetime().optional(),
timeOfArrival: z.string().datetime().optional(),
destinationAddress: z.string().optional(),
+ vehicle: z.string().min(1).optional(),
originalLocationChanged: z.boolean().optional(),
passengerFitRating: z.number().int().min(1).max(5).optional(),
passengerInfo: z.string().optional(),
@@ -61,6 +64,12 @@ export const surveysRouter = createTRPCRouter({
path: ["timeOfArrival"],
message: "Arrival time is required",
});
+ if (!data.vehicle)
+ ctx.addIssue({
+ code: "custom",
+ path: ["vehicle"],
+ message: "Vehicle is required",
+ });
}
if (data.timeOfDeparture && data.timeOfArrival) {
if (new Date(data.timeOfArrival) <= new Date(data.timeOfDeparture)) {
@@ -150,37 +159,87 @@ export const surveysRouter = createTRPCRouter({
});
}
- // create the survey
- const [surveyRow] = await ctx.db
- .insert(postTripSurveys)
- .values({
- bookingId: input.bookingId,
- driverId: booking.driverId,
- tripCompletionStatus: input.tripCompletionStatus,
- startReading: input.startReading,
- endReading: input.endReading,
- timeOfDeparture: input.timeOfDeparture ? new Date(input.timeOfDeparture) : undefined,
- timeOfArrival: input.timeOfArrival ? new Date(input.timeOfArrival) : undefined,
- destinationAddress: input.destinationAddress,
- originalLocationChanged: input.originalLocationChanged,
- passengerFitRating: input.passengerFitRating,
- comments: input.comments,
- passengerInfo: input.passengerInfo,
- })
- .returning();
+ const assignedDriverId = booking.driverId;
+
+ const surveyRow = await ctx.db.transaction(async (tx) => {
+ // create the survey
+ const [createdSurvey] = await tx
+ .insert(postTripSurveys)
+ .values({
+ bookingId: input.bookingId,
+ driverId: assignedDriverId,
+ tripCompletionStatus: input.tripCompletionStatus,
+ startReading: input.startReading,
+ endReading: input.endReading,
+ timeOfDeparture: input.timeOfDeparture ? new Date(input.timeOfDeparture) : undefined,
+ timeOfArrival: input.timeOfArrival ? new Date(input.timeOfArrival) : undefined,
+ destinationAddress: input.destinationAddress,
+ vehicle: input.vehicle,
+ originalLocationChanged: input.originalLocationChanged,
+ passengerFitRating: input.passengerFitRating,
+ comments: input.comments,
+ passengerInfo: input.passengerInfo,
+ })
+ .returning();
+
+ if (!createdSurvey) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create survey",
+ });
+ }
- if (!surveyRow) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to create survey",
- });
- }
+ // Auto-create a driver vehicle log from survey data for non-cancelled trips.
+ if (input.tripCompletionStatus !== BookingStatus.CANCELLED) {
+ if (
+ input.startReading == null ||
+ input.endReading == null ||
+ !input.timeOfDeparture ||
+ !input.timeOfArrival
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot create driver log: missing required odometer or time fields",
+ });
+ }
+
+ const driverRecord = await tx
+ .select({ name: user.name })
+ .from(user)
+ .where(eq(user.id, assignedDriverId))
+ .limit(1)
+ .then((r) => r[0]);
+
+ if (!driverRecord) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Cannot create driver log: assigned driver record not found",
+ });
+ }
- // finally update it so that the booking has survey completed
- await ctx.db
- .update(bookings)
- .set({ surveyCompleted: true })
- .where(eq(bookings.id, input.bookingId));
+ await tx.insert(logs).values({
+ date: dayjs(booking.startTime).format("YYYY-MM-DD"),
+ travelLocation: input.destinationAddress ?? booking.destinationAddress,
+ departureTime: input.timeOfDeparture,
+ arrivalTime: input.timeOfArrival,
+ odometerStart: input.startReading,
+ odometerEnd: input.endReading,
+ driverId: assignedDriverId,
+ driverName: driverRecord.name,
+ vehicle: input.vehicle ?? "Unknown",
+ createdBy: userId,
+ updatedBy: userId,
+ });
+ }
+
+ // finally update it so that the booking has survey completed
+ await tx
+ .update(bookings)
+ .set({ surveyCompleted: true })
+ .where(eq(bookings.id, input.bookingId));
+
+ return createdSurvey;
+ });
return surveyRow;
}),
diff --git a/src/server/api/routers/vehicle-logs.ts b/src/server/api/routers/vehicle-logs.ts
index d63bb9c3..4ab6ac6b 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 or equal to 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 or equal to 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 };
+ }),
});
diff --git a/src/server/api/routers/vehicles.ts b/src/server/api/routers/vehicles.ts
new file mode 100644
index 00000000..b42f74b5
--- /dev/null
+++ b/src/server/api/routers/vehicles.ts
@@ -0,0 +1,90 @@
+import { TRPCError } from "@trpc/server";
+import { generateId } from "better-auth";
+import { asc, eq } from "drizzle-orm";
+import { z } from "zod";
+import { adminProcedure, createTRPCRouter, publicProcedure } from "@/server/api/trpc";
+import { vehicles } from "@/server/db/vehicles-schema";
+
+export const vehiclesRouter = createTRPCRouter({
+ getAll: publicProcedure.query(async ({ ctx }) => {
+ return await ctx.db
+ .select()
+ .from(vehicles)
+ .where(eq(vehicles.active, true))
+ .orderBy(asc(vehicles.name));
+ }),
+
+ getAllAdmin: adminProcedure.query(async ({ ctx }) => {
+ return await ctx.db.select().from(vehicles).orderBy(asc(vehicles.name));
+ }),
+
+ create: adminProcedure
+ .input(
+ z.object({
+ name: z.string().min(1, "Vehicle name is required"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const name = input.name.trim();
+ const existingVehicle = await ctx.db
+ .select({ id: vehicles.id })
+ .from(vehicles)
+ .where(eq(vehicles.name, name))
+ .limit(1);
+
+ if (existingVehicle.length > 0) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "That vehicle already exists.",
+ });
+ }
+
+ const id = generateId();
+ await ctx.db.insert(vehicles).values({
+ id,
+ name,
+ active: true,
+ });
+ return { id, name, active: true };
+ }),
+
+ update: adminProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1, "Vehicle name is required"),
+ active: z.boolean(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const name = input.name.trim();
+ const existingVehicle = await ctx.db
+ .select({ id: vehicles.id })
+ .from(vehicles)
+ .where(eq(vehicles.name, name))
+ .limit(1);
+
+ if (existingVehicle.length > 0 && existingVehicle[0]?.id !== input.id) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "That vehicle already exists.",
+ });
+ }
+
+ await ctx.db
+ .update(vehicles)
+ .set({
+ name,
+ active: input.active,
+ updatedAt: new Date(),
+ })
+ .where(eq(vehicles.id, input.id));
+
+ return { success: true };
+ }),
+
+ delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
+ await ctx.db.update(vehicles).set({ active: false }).where(eq(vehicles.id, input.id));
+ return { success: true };
+ }),
+});
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
index 3d8d6907..9107d637 100644
--- a/src/server/db/index.ts
+++ b/src/server/db/index.ts
@@ -7,6 +7,7 @@ import * as passengerSchema from "@/server/db/passenger-schema";
import * as postTripSchema from "@/server/db/post-trip-schema";
import * as schema from "@/server/db/schema";
import * as logSchema from "@/server/db/vehicle-log";
+import * as vehiclesSchema from "@/server/db/vehicles-schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
@@ -27,5 +28,6 @@ export const db = drizzle(conn, {
...postTripSchema,
...logSchema,
...passengerSchema,
+ ...vehiclesSchema,
},
});
diff --git a/src/server/db/post-trip-schema.ts b/src/server/db/post-trip-schema.ts
index 822b55ac..49f4d58b 100644
--- a/src/server/db/post-trip-schema.ts
+++ b/src/server/db/post-trip-schema.ts
@@ -34,6 +34,7 @@ export const postTripSurveys = pgTable(
timeOfDeparture: timestamp("time_of_departure"),
timeOfArrival: timestamp("time_of_arrival"),
destinationAddress: text("destination_address"),
+ vehicle: text("vehicle"),
// Driver feedback
originalLocationChanged: boolean("original_location_changed"),
diff --git a/src/server/db/vehicle-log.ts b/src/server/db/vehicle-log.ts
index efd2c68c..54ddca20 100644
--- a/src/server/db/vehicle-log.ts
+++ b/src/server/db/vehicle-log.ts
@@ -39,7 +39,7 @@ export const logs = pgTable(
updatedBy: text("updated_by").references(() => user.id),
},
(table) => [
- check("odometer_check", sql`${table.odometerEnd} > ${table.odometerStart}`),
+ check("odometer_check", sql`${table.odometerEnd} >= ${table.odometerStart}`),
check("time_check", sql`${table.arrivalTime} > ${table.departureTime}`),
],
);
diff --git a/src/server/db/vehicles-schema.ts b/src/server/db/vehicles-schema.ts
new file mode 100644
index 00000000..c2444700
--- /dev/null
+++ b/src/server/db/vehicles-schema.ts
@@ -0,0 +1,9 @@
+import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
+
+export const vehicles = pgTable("vehicles", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull().unique(),
+ active: boolean("active").default(true).notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});