diff --git a/drizzle.config.ts b/drizzle.config.ts index 80f709b6..db3060cc 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "./src/server/db/post-trip-schema.ts", // post-trip schema "./src/server/db/vehicle-log.ts", // logs schema "./src/server/db/passenger-schema.ts", // passenger schema + "./src/server/db/vehicles-schema.ts", // vehicles schema ], dialect: "postgresql", dbCredentials: { diff --git a/drizzle/seed.ts b/drizzle/seed.ts index 8b82d9af..e8cae8f1 100644 --- a/drizzle/seed.ts +++ b/drizzle/seed.ts @@ -1,11 +1,12 @@ /** biome-ignore-all lint/style/noNonNullAssertion: its just a seeding script so i think this is fine */ import { generateId } from "better-auth"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { db } from "@/server/db"; import { member, organization, type User, user } from "@/server/db/auth-schema"; import { bookings } from "@/server/db/booking-schema"; import { logs } from "@/server/db/vehicle-log"; +import { vehicles } from "@/server/db/vehicles-schema"; import { BookingStatus } from "@/types/types"; // Parse command line arguments @@ -14,6 +15,7 @@ const flags = { users: args.includes("--users"), bookings: args.includes("--bookings"), logs: args.includes("--logs"), + vehicles: args.includes("--vehicles"), clear: args.includes("--clear"), }; @@ -22,6 +24,7 @@ if (!flags.users && !flags.bookings && !flags.logs) { flags.users = true; flags.bookings = true; flags.logs = true; + flags.vehicles = true; } const DEFAULT_PASSWORD = "Password123!"; @@ -557,6 +560,55 @@ async function seedLogs() { } } +// ==================== SEED VEHICLES ==================== +async function seedVehicles() { + console.log("\n=== SEEDING VEHICLES ===\n"); + + try { + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "vehicles" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL UNIQUE, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL + ); + `); + + const vehicleData = [ + "Ford Expedition CTW 2776", + "Salvation Army Vehicle 2", + "Salvation Army Vehicle 3", + ]; + + console.log(`Creating ${vehicleData.length} vehicles...`); + + for (const vehicleName of vehicleData) { + const existing = await db + .select() + .from(vehicles) + .where(eq(vehicles.name, vehicleName)) + .limit(1); + + if (existing.length === 0) { + await db.insert(vehicles).values({ + id: generateId(), + name: vehicleName, + active: true, + }); + console.log(` ✓ Created: ${vehicleName}`); + } else { + console.log(` → Already exists: ${vehicleName}`); + } + } + + console.log("\n✅ Vehicles seeded successfully!\n"); + } catch (error) { + console.error("❌ Failed to seed vehicles:", error); + throw error; + } +} + async function main() { console.log("\nStarting seed process..."); console.log(`Flags: ${JSON.stringify(flags, null, 2)}\n`); @@ -574,6 +626,10 @@ async function main() { await seedLogs(); } + if (flags.vehicles) { + await seedVehicles(); + } + console.log("\n" + "=".repeat(50)); console.log("SEED COMPLETED SUCCESSFULLY"); console.log("=".repeat(50)); diff --git a/src/app/_components/common/navbar.tsx b/src/app/_components/common/navbar.tsx index cd8ba819..7b2fd301 100644 --- a/src/app/_components/common/navbar.tsx +++ b/src/app/_components/common/navbar.tsx @@ -54,7 +54,8 @@ export default function Navbar({ view, agencyName }: NavbarProps) { Invite Rider Logs - Vehicle Logs + Vehicle Logs + View Vehicles View Schedule diff --git a/src/app/_components/drivercomponents/TripSurveyModal.tsx b/src/app/_components/drivercomponents/TripSurveyModal.tsx index 9aeac7fc..d192e74b 100644 --- a/src/app/_components/drivercomponents/TripSurveyModal.tsx +++ b/src/app/_components/drivercomponents/TripSurveyModal.tsx @@ -1,9 +1,10 @@ "use client"; -import { Group, Radio, Rating, Stack, Text, Textarea, TextInput } from "@mantine/core"; +import { Group, Radio, Rating, Select, Stack, Text, Textarea, TextInput } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; import type { UseFormReturnType } from "@mantine/form"; import dayjs from "dayjs"; +import { useVehicles } from "@/app/hooks/useVehicles"; import Rating1 from "@/assets/icons/rating1"; import Rating2 from "@/assets/icons/rating2"; import Rating3 from "@/assets/icons/rating3"; @@ -20,6 +21,7 @@ interface SurveyForm { timeOfDeparture: string; timeOfArrival: string; destinationAddress: string; + vehicle: string; originalLocationChanged: boolean; passengerFitRating: number | ""; comments: string; @@ -31,6 +33,7 @@ interface SurveyFormProps { export const TripSurveyModal = ({ form }: SurveyFormProps) => { const now = new Date(); + const { vehicleOptions, isLoading: isLoadingVehicles } = useVehicles(); const getIconStyle = (color?: string) => ({ width: 32, @@ -97,6 +100,19 @@ export const TripSurveyModal = ({ form }: SurveyFormProps) => { {...form.getInputProps("destinationAddress")} required /> + + +
validateStringLength(value, 1, 300, "Destination address"), + vehicle: (value, values) => { + if (values.tripCompletionStatus === BookingStatus.CANCELLED) return null; + return validateStringLength(value, 1, 100, "Vehicle"); + }, originalLocationChanged: (value, values) => { if (values.tripCompletionStatus === BookingStatus.CANCELLED) return null; if (typeof value !== "boolean") return "Please select an option"; @@ -166,6 +171,7 @@ export default function SurveyNotification({ survey, completed = false }: Survey submitSurveyMutation.mutate({ bookingId: survey.bookingId as number, // TODO: will remove eventually! driverId: survey.driverId as string, // TODO: will remove castin eveentually + vehicle: form.values.vehicle, ...basePayload, }); }; diff --git a/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx b/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx index 93a3f858..fb69a353 100644 --- a/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx +++ b/src/app/_components/vehiclelogcomponents/vehicle-log-form.tsx @@ -1,11 +1,13 @@ "use client"; -import { Box, Divider, Stack, TextInput } from "@mantine/core"; +import { Box, Divider, Select, Stack, TextInput } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; import type { UseFormReturnType } from "@mantine/form"; +import { useVehicles } from "@/app/hooks/useVehicles"; import classes from "./vehicle-log-form.module.scss"; interface VehicleLogFormData { + id: number | null; date: string | null; destination: string; departureTime: string | null; @@ -24,6 +26,7 @@ export const VehicleLogForm = ({ form }: VehicleLogFormProps) => { const now = new Date(); const ninetyDaysAgo = new Date(); ninetyDaysAgo.setDate(now.getDate() - 90); + const { vehicleOptions, isLoading: isLoadingVehicles } = useVehicles(); // Calculate KM driven const calculateKmDriven = () => { @@ -174,10 +177,13 @@ export const VehicleLogForm = ({ form }: VehicleLogFormProps) => {
- 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. + + +