Explorar el Código

Add sortable columns to the customers and vehicles lists (#223)

* Add sortable columns to the customers and vehicles lists

Clickable column headers following the work-orders pattern: sort state
lives in the URL (sortBy/sortOrder), the server re-fetches with a
Prisma orderBy switch, and sorting resets to page 1. Customers sort by
name, company, phone, email or vehicle count; vehicles by plate,
vehicle (make, model, year), customer, mileage or service count.
Default order stays updatedAt desc.

* Stabilize list column widths while sorting

table-fixed layout with explicit widths on both lists, so columns no
longer resize as page data changes; long values truncate with ellipsis
instead of stretching their column.

* Add vehicle and customer sorting to the work orders list

The two columns had no sort. Vehicle sorts by make, model, year;
customer by name through the vehicle relation. Same table-fixed layout
and truncation as the customers and vehicles lists so columns stay
still while sorting.

* Add sortable columns to the quotes and inspections lists

Quotes sort by number, title, customer, vehicle (make/model/year),
status, date or total; inspections by vehicle, template, status or
date (progress is computed client-side and stays unsortable). Same
URL-driven server-side pattern and table-fixed stable layout as the
other lists.

* Add sortable columns to the billing list

Invoice, title, vehicle, customer, date and total are sortable in both
query paths: the raw SQL path orders by the computed effective total
and coalesced invoice date, the Prisma path mirrors it. Paid and
balance stay unsortable (computed from payments at render). Same
fixed-layout stable columns as the other lists.

* Fix billing date sorting and clipped status badge

Billing now always uses the raw SQL query, so date sorting is an exact
COALESCE(invoiceDate, startDateTime, serviceDate) matching the
displayed date, and total sorts by the computed effective total in all
tabs; the approximate Prisma path is gone. Shared date-sort helpers in
src/lib/date-sort.ts are used by billing (SQL) and the work orders
list (Prisma). The balance column is wider with nowrap so the payment
status badge no longer clips.
Bernt Christian Egeland hace 3 meses
padre
commit
d97135802a

+ 64 - 14
src/app/(authenticated)/billing/billing-client.tsx

@@ -26,6 +26,9 @@ import {
   DollarSign,
   TrendingUp,
   AlertCircle,
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
 } from "lucide-react";
 import { cn } from "@/lib/utils";
 import { useFormatCurrency } from '@/components/currency-settings-context'
@@ -73,6 +76,8 @@ interface BillingClientProps {
   currencyCode?: string;
   search: string;
   statusFilter: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }
 
 const STATUS_TABS = [
@@ -87,6 +92,8 @@ export default function BillingClient({
   currencyCode = "USD",
   search,
   statusFilter,
+  sortBy = "",
+  sortOrder = "desc",
 }: BillingClientProps) {
   const formatCurrency = useFormatCurrency();
   const router = useRouter();
@@ -111,6 +118,25 @@ export default function BillingClient({
     [searchParams]
   );
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      startTransition(() => {
+        router.push(
+          `${pathname}?${createQueryString({ sortBy: column, sortOrder: newOrder, page: "1" })}`
+        );
+      });
+    },
+    [createQueryString, pathname, router, sortBy, sortOrder]
+  );
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
+    return sortOrder === "asc"
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />;
+  };
+
   const handleStatusChange = (status: string) => {
     startTransition(() => {
       router.push(
@@ -292,17 +318,41 @@ export default function BillingClient({
 
       {/* Billing Table */}
       <div className="rounded-md border">
-        <Table>
+        <Table className="table-fixed">
           <TableHeader>
             <TableRow>
-              <TableHead>{t("history.columnInvoice")}</TableHead>
-              <TableHead>{t("history.columnTitle")}</TableHead>
-              <TableHead>{t("history.columnVehicle")}</TableHead>
-              <TableHead>{t("history.columnCustomer")}</TableHead>
-              <TableHead>{t("history.columnDate")}</TableHead>
-              <TableHead className="text-right">{t("history.columnTotal")}</TableHead>
-              <TableHead className="text-right">{t("history.columnPaid")}</TableHead>
-              <TableHead className="text-right">{t("history.columnBalance")}</TableHead>
+              <TableHead className="w-[110px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("invoiceNumber")}>
+                  {t("history.columnInvoice")}<SortIcon column="invoiceNumber" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("title")}>
+                  {t("history.columnTitle")}<SortIcon column="title" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[16%]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("vehicle")}>
+                  {t("history.columnVehicle")}<SortIcon column="vehicle" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[14%]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("customer")}>
+                  {t("history.columnCustomer")}<SortIcon column="customer" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[110px]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("date")}>
+                  {t("history.columnDate")}<SortIcon column="date" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[100px]">
+                <button type="button" className="ml-auto flex items-center hover:text-foreground" onClick={() => handleSort("total")}>
+                  {t("history.columnTotal")}<SortIcon column="total" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[100px] text-right">{t("history.columnPaid")}</TableHead>
+              <TableHead className="w-[180px] text-right">{t("history.columnBalance")}</TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
@@ -321,15 +371,15 @@ export default function BillingClient({
                     className="cursor-pointer hover:bg-muted/50"
                     onClick={() => handleRowClick(record)}
                   >
-                    <TableCell className="font-medium">
+                    <TableCell className="truncate font-medium">
                       {record.invoiceNumber || "\u2014"}
                     </TableCell>
-                    <TableCell>{record.title}</TableCell>
-                    <TableCell>
+                    <TableCell className="truncate">{record.title}</TableCell>
+                    <TableCell className="truncate">
                       {record.vehicle.year} {record.vehicle.make}{" "}
                       {record.vehicle.model}
                     </TableCell>
-                    <TableCell>
+                    <TableCell className="truncate">
                       {record.vehicle.customer?.name || "\u2014"}
                     </TableCell>
                     <TableCell>
@@ -343,7 +393,7 @@ export default function BillingClient({
                     </TableCell>
                     <TableCell
                       className={cn(
-                        "text-right font-medium",
+                        "whitespace-nowrap text-right font-medium",
                         getBalanceColor(record.status)
                       )}
                     >

+ 7 - 1
src/app/(authenticated)/billing/page.tsx

@@ -12,6 +12,8 @@ export default async function BillingPage({
     pageSize?: string;
     search?: string;
     status?: string;
+    sortBy?: string;
+    sortOrder?: string;
   }>;
 }) {
   const params = await searchParams;
@@ -19,9 +21,11 @@ export default async function BillingPage({
   const pageSize = Number(params.pageSize) || 20;
   const search = params.search || "";
   const statusFilter = params.status || "all";
+  const sortBy = params.sortBy || "";
+  const sortOrder = (params.sortOrder as "asc" | "desc") || "desc";
 
   const [result, settingsResult] = await Promise.all([
-    getBillingHistory({ page, pageSize, search, status: statusFilter }),
+    getBillingHistory({ page, pageSize, search, status: statusFilter, sortBy, sortOrder }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
   ]);
 
@@ -48,6 +52,8 @@ export default async function BillingPage({
           currencyCode={currencyCode}
           search={search}
           statusFilter={statusFilter}
+          sortBy={sortBy}
+          sortOrder={sortOrder}
         />
       </div>
     </>

+ 53 - 11
src/app/(authenticated)/customers/customers-client.tsx

@@ -30,6 +30,9 @@ import { Checkbox } from "@/components/ui/checkbox";
 import { deleteCustomer, deleteCustomers } from "@/features/customers/Actions/customerActions";
 import { toast } from "sonner";
 import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
   Loader2,
   MoreVertical,
   Pencil,
@@ -64,9 +67,13 @@ interface PaginatedData {
 export function CustomersClient({
   data,
   search,
+  sortBy,
+  sortOrder,
 }: {
   data: PaginatedData;
   search: string;
+  sortBy: string;
+  sortOrder: "asc" | "desc";
 }) {
   const t = useTranslations("customers.list");
   const tc = useTranslations("common");
@@ -102,7 +109,7 @@ export function CustomersClient({
           newParams.set(key, String(value));
         }
       }
-      if (!("page" in params) && "search" in params) {
+      if (!("page" in params) && ("search" in params || "sortBy" in params)) {
         newParams.delete("page");
       }
       startTransition(() => {
@@ -112,6 +119,21 @@ export function CustomersClient({
     [router, pathname, searchParams]
   );
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      navigate({ sortBy: column, sortOrder: newOrder });
+    },
+    [navigate, sortBy, sortOrder]
+  );
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
+    return sortOrder === "asc"
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />;
+  };
+
   // Live search: filters as you type, no Enter required. Submitting the
   // form (Enter) commits immediately, bypassing the debounce.
   const {
@@ -227,7 +249,7 @@ export function CustomersClient({
 
       {/* Table - only this scrolls */}
       <div className="overflow-auto rounded-lg border max-h-[calc(100vh-220px)]">
-        <Table>
+        <Table className="table-fixed">
           <TableHeader className="sticky top-0 z-10 bg-background">
             <TableRow>
               <TableHead className="w-[40px]">
@@ -243,11 +265,31 @@ export function CustomersClient({
                   onClick={(e) => e.stopPropagation()}
                 />
               </TableHead>
-              <TableHead>{t("table.name")}</TableHead>
-              <TableHead className="hidden sm:table-cell">{t("table.company")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("table.phone")}</TableHead>
-              <TableHead className="hidden lg:table-cell">{t("table.email")}</TableHead>
-              <TableHead className="w-[80px] text-center">{t("table.vehicles")}</TableHead>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
+                  {t("table.name")}<SortIcon column="name" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[20%] sm:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("company")}>
+                  {t("table.company")}<SortIcon column="company" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[16%] md:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("phone")}>
+                  {t("table.phone")}<SortIcon column="phone" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[22%] lg:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("email")}>
+                  {t("table.email")}<SortIcon column="email" />
+                </button>
+              </TableHead>
+              <TableHead className="w-[80px]">
+                <button type="button" className="mx-auto flex items-center hover:text-foreground" onClick={() => handleSort("vehicles")}>
+                  {t("table.vehicles")}<SortIcon column="vehicles" />
+                </button>
+              </TableHead>
               <TableHead className="w-[50px]" />
             </TableRow>
           </TableHeader>
@@ -275,16 +317,16 @@ export function CustomersClient({
                   <TableCell>
                     <div className="flex items-center gap-2">
                       <Users className="h-4 w-4 text-muted-foreground shrink-0" />
-                      <span className="font-medium">{c.name}</span>
+                      <span className="truncate font-medium">{c.name}</span>
                     </div>
                   </TableCell>
-                  <TableCell className="hidden sm:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate sm:table-cell text-muted-foreground">
                     {c.company || "-"}
                   </TableCell>
-                  <TableCell className="hidden md:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate md:table-cell text-muted-foreground">
                     {c.phone || "-"}
                   </TableCell>
-                  <TableCell className="hidden lg:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
                     {c.email || "-"}
                   </TableCell>
                   <TableCell className="text-center">{c._count.vehicles}</TableCell>

+ 11 - 1
src/app/(authenticated)/customers/page.tsx

@@ -6,13 +6,21 @@ import { PageHeader } from "@/components/page-header";
 export default async function CustomersPage({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string; pageSize?: string; search?: string }>;
+  searchParams: Promise<{
+    page?: string;
+    pageSize?: string;
+    search?: string;
+    sortBy?: string;
+    sortOrder?: string;
+  }>;
 }) {
   const params = await searchParams;
   const result = await getCustomersPaginated({
     page: params.page ? parseInt(params.page) : 1,
     pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
     search: params.search,
+    sortBy: params.sortBy,
+    sortOrder: params.sortOrder as "asc" | "desc" | undefined,
   });
 
   if (!result.success || !result.data) {
@@ -36,6 +44,8 @@ export default async function CustomersPage({
         <CustomersClient
           data={result.data}
           search={params.search || ""}
+          sortBy={params.sortBy || ""}
+          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
         />
       </div>
     </>

+ 44 - 9
src/app/(authenticated)/inspections/inspections-client.tsx

@@ -12,7 +12,7 @@ import {
   Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
 } from "@/components/ui/table";
 import { DataTablePagination } from "@/components/data-table-pagination";
-import { Loader2, Plus, Search } from "lucide-react";
+import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, Plus, Search } from "lucide-react";
 import { NewInspectionDialog } from "@/features/inspections/Components/NewInspectionDialog";
 
 interface InspectionRecord {
@@ -93,11 +93,15 @@ export function InspectionsClient({
   templates,
   search,
   statusFilter,
+  sortBy = "",
+  sortOrder = "desc",
 }: {
   data: PaginatedData;
   templates: TemplateOption[];
   search: string;
   statusFilter: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   const router = useRouter();
   const { formatDate } = useFormatDate();
@@ -132,6 +136,21 @@ export function InspectionsClient({
     commitNow: handleSearch,
   } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      navigate({ sortBy: column, sortOrder: newOrder });
+    },
+    [navigate, sortBy, sortOrder]
+  );
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
+    return sortOrder === "asc"
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />;
+  };
+
   return (
     <div className="space-y-4">
       <div className="flex flex-wrap gap-2">
@@ -176,14 +195,30 @@ export function InspectionsClient({
       </div>
 
       <div className="rounded-lg border">
-        <Table>
+        <Table className="table-fixed">
           <TableHeader>
             <TableRow>
-              <TableHead>Vehicle</TableHead>
-              <TableHead className="hidden md:table-cell">Template</TableHead>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("vehicle")}>
+                  Vehicle<SortIcon column="vehicle" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[24%] md:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("template")}>
+                  Template<SortIcon column="template" />
+                </button>
+              </TableHead>
               <TableHead className="w-32">Progress</TableHead>
-              <TableHead className="w-28">Status</TableHead>
-              <TableHead className="w-24">Date</TableHead>
+              <TableHead className="w-28">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("status")}>
+                  Status<SortIcon column="status" />
+                </button>
+              </TableHead>
+              <TableHead className="w-24">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("createdAt")}>
+                  Date<SortIcon column="createdAt" />
+                </button>
+              </TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
@@ -201,8 +236,8 @@ export function InspectionsClient({
                   onClick={() => router.push(`/inspections/${insp.id}`)}
                 >
                   <TableCell>
-                    <div>
-                      <p className="font-medium">
+                    <div className="min-w-0">
+                      <p className="truncate font-medium">
                         {insp.vehicle.year} {insp.vehicle.make} {insp.vehicle.model}
                       </p>
                       {insp.vehicle.licensePlate && (
@@ -212,7 +247,7 @@ export function InspectionsClient({
                       )}
                     </div>
                   </TableCell>
-                  <TableCell className="hidden md:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate md:table-cell text-muted-foreground">
                     {insp.template.name}
                   </TableCell>
                   <TableCell>

+ 6 - 0
src/app/(authenticated)/inspections/page.tsx

@@ -11,6 +11,8 @@ export default async function InspectionsPage({
     pageSize?: string;
     search?: string;
     status?: string;
+    sortBy?: string;
+    sortOrder?: string;
   }>;
 }) {
   const params = await searchParams;
@@ -20,6 +22,8 @@ export default async function InspectionsPage({
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status || "all",
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
     }),
     getTemplates(),
   ]);
@@ -48,6 +52,8 @@ export default async function InspectionsPage({
           templates={templates}
           search={params.search || ""}
           statusFilter={params.status || "all"}
+          sortBy={params.sortBy || ""}
+          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
         />
       </div>
     </>

+ 6 - 0
src/app/(authenticated)/quotes/page.tsx

@@ -12,6 +12,8 @@ export default async function QuotesPage({
     pageSize?: string
     search?: string
     status?: string
+    sortBy?: string
+    sortOrder?: string
   }>
 }) {
   const params = await searchParams
@@ -21,6 +23,8 @@ export default async function QuotesPage({
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       status: params.status || 'all',
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
   ])
@@ -48,6 +52,8 @@ export default async function QuotesPage({
           currencyCode={currencyCode}
           search={params.search || ''}
           statusFilter={params.status || 'all'}
+          sortBy={params.sortBy || ''}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
         />
       </div>
     </>

+ 59 - 12
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -18,7 +18,7 @@ import {
   TableRow,
 } from '@/components/ui/table'
 import { DataTablePagination } from '@/components/data-table-pagination'
-import { Loader2, Plus, Search } from 'lucide-react'
+import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, Plus, Search } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 
@@ -72,11 +72,15 @@ export function QuotesClient({
   currencyCode = 'USD',
   search,
   statusFilter,
+  sortBy = '',
+  sortOrder = 'desc',
 }: {
   data: PaginatedData
   currencyCode?: string
   search: string
   statusFilter: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
 }) {
   const formatCurrency = useFormatCurrency()
   const router = useRouter()
@@ -127,6 +131,21 @@ export function QuotesClient({
 
   const openNewDialog = () => setShowNewDialog(true)
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder })
+    },
+    [navigate, sortBy, sortOrder]
+  )
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc'
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />
+  }
+
   return (
     <div className="space-y-4">
       <div className="flex flex-wrap gap-2">
@@ -171,16 +190,44 @@ export function QuotesClient({
       </div>
 
       <div className="rounded-lg border">
-        <Table>
+        <Table className="table-fixed">
           <TableHeader>
             <TableRow>
-              <TableHead className="w-25">{t('list.columnQuoteNumber')}</TableHead>
-              <TableHead>{t('list.columnTitle')}</TableHead>
-              <TableHead className="hidden md:table-cell">{t('list.columnCustomer')}</TableHead>
-              <TableHead className="hidden lg:table-cell">{t('list.columnVehicle')}</TableHead>
-              <TableHead className="w-27.5">{t('list.columnStatus')}</TableHead>
-              <TableHead className="w-22.5">{t('list.columnDate')}</TableHead>
-              <TableHead className="w-22.5 text-right">{t('list.columnTotal')}</TableHead>
+              <TableHead className="w-25">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('quoteNumber')}>
+                  {t('list.columnQuoteNumber')}<SortIcon column="quoteNumber" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('title')}>
+                  {t('list.columnTitle')}<SortIcon column="title" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[18%] md:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('customer')}>
+                  {t('list.columnCustomer')}<SortIcon column="customer" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[18%] lg:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('vehicle')}>
+                  {t('list.columnVehicle')}<SortIcon column="vehicle" />
+                </button>
+              </TableHead>
+              <TableHead className="w-27.5">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('status')}>
+                  {t('list.columnStatus')}<SortIcon column="status" />
+                </button>
+              </TableHead>
+              <TableHead className="w-22.5">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('createdAt')}>
+                  {t('list.columnDate')}<SortIcon column="createdAt" />
+                </button>
+              </TableHead>
+              <TableHead className="w-22.5">
+                <button type="button" className="ml-auto flex items-center hover:text-foreground" onClick={() => handleSort('totalAmount')}>
+                  {t('list.columnTotal')}<SortIcon column="totalAmount" />
+                </button>
+              </TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
@@ -200,11 +247,11 @@ export function QuotesClient({
                   <TableCell className="font-mono text-xs text-muted-foreground">
                     {q.quoteNumber || '-'}
                   </TableCell>
-                  <TableCell className="font-medium">{q.title}</TableCell>
-                  <TableCell className="hidden md:table-cell text-muted-foreground">
+                  <TableCell className="truncate font-medium">{q.title}</TableCell>
+                  <TableCell className="hidden truncate md:table-cell text-muted-foreground">
                     {q.customer?.name || '-'}
                   </TableCell>
-                  <TableCell className="hidden lg:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
                     {q.vehicle ? `${q.vehicle.year} ${q.vehicle.make} ${q.vehicle.model}` : '-'}
                   </TableCell>
                   <TableCell>

+ 12 - 1
src/app/(authenticated)/vehicles/page.tsx

@@ -8,7 +8,14 @@ import { PageHeader } from "@/components/page-header";
 export default async function VehiclesPage({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string; pageSize?: string; search?: string; archived?: string }>;
+  searchParams: Promise<{
+    page?: string;
+    pageSize?: string;
+    search?: string;
+    archived?: string;
+    sortBy?: string;
+    sortOrder?: string;
+  }>;
 }) {
   const params = await searchParams;
   const isArchived = params.archived === "true";
@@ -21,6 +28,8 @@ export default async function VehiclesPage({
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       archived: isArchived,
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
     }),
     getCustomersList(),
   ]);
@@ -46,6 +55,8 @@ export default async function VehiclesPage({
           data={result.data}
           customers={customersResult.data ?? []}
           search={params.search || ""}
+          sortBy={params.sortBy || ""}
+          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
           initialView={initialView}
           isArchived={isArchived}
           archivedCount={result.data.archivedCount}

+ 51 - 9
src/app/(authenticated)/vehicles/vehicles-client.tsx

@@ -33,6 +33,9 @@ import { toast } from 'sonner'
 import {
   Archive,
   ArchiveRestore,
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
   Gauge,
   Grid3X3,
   LayoutGrid,
@@ -88,6 +91,8 @@ export function VehiclesClient({
   data,
   customers,
   search,
+  sortBy = '',
+  sortOrder = 'desc',
   initialView = 'table',
   isArchived = false,
   archivedCount = 0,
@@ -95,6 +100,8 @@ export function VehiclesClient({
   data: PaginatedData
   customers: CustomerOption[]
   search: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
   initialView?: 'table' | 'grid' | 'grid6'
   isArchived?: boolean
   archivedCount?: number
@@ -141,7 +148,7 @@ export function VehiclesClient({
           newParams.set(key, String(value))
         }
       }
-      if (!('page' in params) && 'search' in params) {
+      if (!('page' in params) && ('search' in params || 'sortBy' in params)) {
         newParams.delete('page')
       }
       startTransition(() => {
@@ -151,6 +158,21 @@ export function VehiclesClient({
     [router, pathname, searchParams]
   )
 
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder })
+    },
+    [navigate, sortBy, sortOrder]
+  )
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc'
+      ? <ArrowUp className="ml-1 h-3 w-3" />
+      : <ArrowDown className="ml-1 h-3 w-3" />
+  }
+
   const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
   const handleSearchChange = useCallback(
@@ -285,14 +307,34 @@ export function VehiclesClient({
       ) : view === 'table' ? (
         /* Table view */
         <div className="rounded-lg border">
-          <Table>
+          <Table className="table-fixed">
             <TableHeader>
               <TableRow>
-                <TableHead className="w-[120px]">{t('table.plate')}</TableHead>
-                <TableHead>{t('table.vehicle')}</TableHead>
-                <TableHead className="hidden sm:table-cell">{t('table.customer')}</TableHead>
-                <TableHead className="hidden md:table-cell w-[100px] text-right">{serviceType === 'marine' ? t('table.mileageMarine') : t('table.mileage')}</TableHead>
-                <TableHead className="w-[80px] text-center">{t('table.services')}</TableHead>
+                <TableHead className="w-[120px]">
+                  <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('plate')}>
+                    {t('table.plate')}<SortIcon column="plate" />
+                  </button>
+                </TableHead>
+                <TableHead>
+                  <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('vehicle')}>
+                    {t('table.vehicle')}<SortIcon column="vehicle" />
+                  </button>
+                </TableHead>
+                <TableHead className="hidden w-[24%] sm:table-cell">
+                  <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('customer')}>
+                    {t('table.customer')}<SortIcon column="customer" />
+                  </button>
+                </TableHead>
+                <TableHead className="hidden md:table-cell w-[100px]">
+                  <button type="button" className="ml-auto flex items-center hover:text-foreground" onClick={() => handleSort('mileage')}>
+                    {serviceType === 'marine' ? t('table.mileageMarine') : t('table.mileage')}<SortIcon column="mileage" />
+                  </button>
+                </TableHead>
+                <TableHead className="w-[80px]">
+                  <button type="button" className="mx-auto flex items-center hover:text-foreground" onClick={() => handleSort('services')}>
+                    {t('table.services')}<SortIcon column="services" />
+                  </button>
+                </TableHead>
                 <TableHead className="w-[50px]" />
               </TableRow>
             </TableHeader>
@@ -304,12 +346,12 @@ export function VehiclesClient({
                   onClick={() => router.push(`/vehicles/${v.id}`)}
                 >
                   <TableCell className="font-mono text-sm">{v.licensePlate || '-'}</TableCell>
-                  <TableCell>
+                  <TableCell className="truncate">
                     <span className="font-medium">
                       {v.year} {v.make} {v.model}
                     </span>
                   </TableCell>
-                  <TableCell className="hidden sm:table-cell text-muted-foreground">
+                  <TableCell className="hidden truncate sm:table-cell text-muted-foreground">
                     {v.customer?.name || '-'}
                   </TableCell>
                   <TableCell className="hidden md:table-cell text-right font-mono text-sm">

+ 16 - 8
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -275,7 +275,7 @@ export function WorkOrdersClient({
 
       {/* Table */}
       <div className="rounded-lg border">
-        <Table>
+        <Table className="table-fixed">
           <TableHeader>
             <TableRow>
               <TableHead className="hidden sm:table-cell w-[100px]">
@@ -283,8 +283,16 @@ export function WorkOrdersClient({
                   {t("table.invoice")}<SortIcon column="invoiceNumber" />
                 </button>
               </TableHead>
-              <TableHead>{t("table.vehicle")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("table.customer")}</TableHead>
+              <TableHead className="w-[18%]">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("vehicle")}>
+                  {t("table.vehicle")}<SortIcon column="vehicle" />
+                </button>
+              </TableHead>
+              <TableHead className="hidden w-[14%] md:table-cell">
+                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("customer")}>
+                  {t("table.customer")}<SortIcon column="customer" />
+                </button>
+              </TableHead>
               <TableHead>
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("title")}>
                   {t("table.title")}<SortIcon column="title" />
@@ -295,7 +303,7 @@ export function WorkOrdersClient({
                   {t("table.status")}<SortIcon column="status" />
                 </button>
               </TableHead>
-              <TableHead className="hidden lg:table-cell">
+              <TableHead className="hidden w-[12%] lg:table-cell">
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("techName")}>
                   {t("table.tech")}<SortIcon column="techName" />
                 </button>
@@ -337,19 +345,19 @@ export function WorkOrdersClient({
                       {r.invoiceNumber || "-"}
                     </TableCell>
                     <TableCell>
-                      <div>
+                      <div className="min-w-0">
                         {r.vehicle.licensePlate && (
                           <span className="font-mono text-sm font-medium">{r.vehicle.licensePlate}</span>
                         )}
-                        <p className="text-xs text-muted-foreground">
+                        <p className="truncate text-xs text-muted-foreground">
                           {r.vehicle.year} {r.vehicle.make} {r.vehicle.model}
                         </p>
                       </div>
                     </TableCell>
-                    <TableCell className="hidden md:table-cell text-muted-foreground">
+                    <TableCell className="hidden truncate md:table-cell text-muted-foreground">
                       {r.vehicle.customer?.name || "-"}
                     </TableCell>
-                    <TableCell>
+                    <TableCell className="truncate">
                       <span className="font-medium">{r.title}</span>
                     </TableCell>
                     <TableCell>

+ 22 - 74
src/features/billing/Actions/billingActions.ts

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { Prisma } from "@/generated/prisma/client";
+import { effectiveDateSql } from "@/lib/date-sort";
 
 interface BillingSummary {
   totalRevenue: number;
@@ -64,6 +65,8 @@ export async function getBillingHistory(params: {
   pageSize?: number;
   search?: string;
   status?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ organizationId }) => {
     const page = params.page || 1;
@@ -72,21 +75,10 @@ export async function getBillingHistory(params: {
 
     const summary = await getBillingSummary(organizationId);
 
-    // Build where clause
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const where: any = { vehicle: { organizationId } };
-
-    if (params.search) {
-      where.OR = [
-        { title: { contains: params.search, mode: "insensitive" } },
-        { invoiceNumber: { contains: params.search, mode: "insensitive" } },
-        { vehicle: { make: { contains: params.search, mode: "insensitive" } } },
-        { vehicle: { model: { contains: params.search, mode: "insensitive" } } },
-      ];
-    }
-
-    // When filtering by payment status, use raw SQL for efficiency
-    if (params.status === "paid" || params.status === "partial" || params.status === "unpaid") {
+    // Raw SQL for every path: payment status and effective total are computed
+    // values, and the displayed date is COALESCE(invoiceDate, startDateTime,
+    // serviceDate) — only SQL can filter and sort by them exactly.
+    {
       const searchCondition = params.search
         ? Prisma.sql`AND (
             sr.title ILIKE ${`%${params.search}%`}
@@ -101,8 +93,10 @@ export async function getBillingHistory(params: {
         statusCondition = Prisma.sql`AND (manually_paid = true OR (paid_amount >= effective_total AND effective_total > 0))`;
       } else if (params.status === "partial") {
         statusCondition = Prisma.sql`AND manually_paid = false AND paid_amount > 0 AND paid_amount < effective_total`;
-      } else {
+      } else if (params.status === "unpaid") {
         statusCondition = Prisma.sql`AND manually_paid = false AND paid_amount = 0`;
+      } else {
+        statusCondition = Prisma.empty;
       }
 
       const countRows = await db.$queryRaw<{ cnt: bigint }[]>(Prisma.sql`
@@ -190,7 +184,18 @@ export async function getBillingHistory(params: {
           ${searchCondition}
         ) sub
         WHERE 1=1 ${statusCondition}
-        ORDER BY COALESCE(sub."invoiceDate", sub."startDateTime", sub."serviceDate") DESC
+        ORDER BY ${(() => {
+          const dir = params.sortOrder === "asc" ? Prisma.sql`ASC` : Prisma.sql`DESC`;
+          switch (params.sortBy) {
+            case "invoiceNumber": return Prisma.sql`sub."invoiceNumber" ${dir} NULLS LAST`;
+            case "title": return Prisma.sql`sub.title ${dir}`;
+            case "vehicle": return Prisma.sql`sub.vehicle_make ${dir}, sub.vehicle_model ${dir}, sub.vehicle_year ${dir}`;
+            case "customer": return Prisma.sql`sub.customer_name ${dir} NULLS LAST`;
+            case "total": return Prisma.sql`sub.effective_total ${dir}`;
+            case "date": return Prisma.sql`${effectiveDateSql("sub")} ${dir}`;
+            default: return Prisma.sql`${effectiveDateSql("sub")} DESC`;
+          }
+        })()}
         LIMIT ${pageSize} OFFSET ${skip}
       `);
 
@@ -230,62 +235,5 @@ export async function getBillingHistory(params: {
         summary,
       };
     }
-
-    // No status filter — use Prisma for clean pagination
-    const [records, total] = await Promise.all([
-      db.serviceRecord.findMany({
-        where,
-        include: {
-          payments: { select: { amount: true } },
-          vehicle: {
-            select: {
-              id: true,
-              make: true,
-              model: true,
-              year: true,
-              licensePlate: true,
-              customer: { select: { id: true, name: true } },
-            },
-          },
-        },
-        orderBy: [
-          { invoiceDate: { sort: "desc", nulls: "last" } },
-          { startDateTime: { sort: "desc", nulls: "last" } },
-          { serviceDate: "desc" },
-        ],
-        skip,
-        take: pageSize,
-      }),
-      db.serviceRecord.count({ where }),
-    ]);
-
-    return {
-      records: records.map((r) => {
-        const effectiveTotal = r.totalAmount > 0 ? r.totalAmount : r.cost;
-        const paidFromPayments = r.payments.reduce((s, p) => s + p.amount, 0);
-        const totalPaid = r.manuallyPaid ? effectiveTotal : paidFromPayments;
-        const paymentStatus = r.manuallyPaid || (totalPaid >= effectiveTotal && effectiveTotal > 0)
-          ? "paid"
-          : totalPaid > 0
-            ? "partial"
-            : "unpaid";
-        return {
-          id: r.id,
-          title: r.title,
-          invoiceNumber: r.invoiceNumber,
-          startDateTime: r.startDateTime,
-          serviceDate: r.invoiceDate ?? r.startDateTime ?? r.serviceDate,
-          totalAmount: effectiveTotal,
-          totalPaid,
-          status: paymentStatus,
-          vehicle: r.vehicle,
-        };
-      }),
-      total,
-      page,
-      pageSize,
-      totalPages: Math.ceil(total / pageSize),
-      summary,
-    };
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.BILLING }] });
 }

+ 14 - 1
src/features/customers/Actions/customerActions.ts

@@ -142,6 +142,8 @@ export async function getCustomersPaginated(params: {
   page?: number;
   pageSize?: number;
   search?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ userId, organizationId }) => {
     const page = params.page || 1;
@@ -178,7 +180,18 @@ export async function getCustomersPaginated(params: {
         include: {
           _count: { select: { vehicles: true } },
         },
-        orderBy: { updatedAt: "desc" },
+        orderBy: (() => {
+          const dir = params.sortOrder || "desc";
+          const nullable = { sort: dir, nulls: "last" } as const;
+          switch (params.sortBy) {
+            case "name": return { name: dir };
+            case "company": return { company: nullable };
+            case "phone": return { phone: nullable };
+            case "email": return { email: nullable };
+            case "vehicles": return { vehicles: { _count: dir } };
+            default: return { updatedAt: "desc" as const };
+          }
+        })(),
         skip,
         take: pageSize,
       }),

+ 18 - 1
src/features/inspections/Actions/inspectionActions.ts

@@ -12,6 +12,8 @@ export async function getInspectionsPaginated(params: {
   pageSize?: number;
   search?: string;
   status?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ organizationId }) => {
     const page = params.page || 1;
@@ -42,7 +44,22 @@ export async function getInspectionsPaginated(params: {
           template: { select: { id: true, name: true } },
           items: { select: { id: true, condition: true } },
         },
-        orderBy: { createdAt: "desc" },
+        orderBy: (() => {
+          const dir = params.sortOrder || "desc";
+          switch (params.sortBy) {
+            // Column shows "year make model"; sort make, model, year so
+            // identical models group together
+            case "vehicle": return [
+              { vehicle: { make: dir } },
+              { vehicle: { model: dir } },
+              { vehicle: { year: dir } },
+            ];
+            case "template": return { template: { name: dir } };
+            case "status": return { status: dir };
+            case "createdAt": return { createdAt: dir };
+            default: return { createdAt: "desc" as const };
+          }
+        })(),
         skip,
         take: pageSize,
       }),

+ 21 - 1
src/features/quotes/Actions/quoteActions.ts

@@ -28,6 +28,8 @@ export async function getQuotesPaginated(params: {
   pageSize?: number;
   search?: string;
   status?: string;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ userId, organizationId }) => {
     const page = params.page || 1;
@@ -56,7 +58,25 @@ export async function getQuotesPaginated(params: {
           customer: { select: { id: true, name: true } },
           vehicle: { select: { id: true, make: true, model: true, year: true, licensePlate: true } },
         },
-        orderBy: { createdAt: "desc" },
+        orderBy: (() => {
+          const dir = params.sortOrder || "desc";
+          switch (params.sortBy) {
+            case "quoteNumber": return { quoteNumber: { sort: dir, nulls: "last" as const } };
+            case "title": return { title: dir };
+            case "customer": return { customer: { name: dir } };
+            // Column shows "year make model"; sort make, model, year so
+            // identical models group together
+            case "vehicle": return [
+              { vehicle: { make: dir } },
+              { vehicle: { model: dir } },
+              { vehicle: { year: dir } },
+            ];
+            case "status": return { status: dir };
+            case "totalAmount": return { totalAmount: dir };
+            case "createdAt": return { createdAt: dir };
+            default: return { createdAt: "desc" as const };
+          }
+        })(),
         skip,
         take: pageSize,
       }),

+ 10 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -9,6 +9,7 @@ import { unlink } from "fs/promises";
 import { randomUUID } from "crypto";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { resolveInvoicePrefix, toSafeDate } from "@/lib/invoice-utils";
+import { serviceDateOrderBy } from "@/lib/date-sort";
 import { notificationBus } from "@/lib/notification-bus";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileStock";
@@ -707,8 +708,16 @@ export async function getWorkOrders(params: {
             case "status": return { status: dir };
             case "techName": return { techName: dir };
             case "totalAmount": return { totalAmount: dir };
+            // Column shows "year make model"; sort make, model, year so
+            // identical models group together
+            case "vehicle": return [
+              { vehicle: { make: dir } },
+              { vehicle: { model: dir } },
+              { vehicle: { year: dir } },
+            ];
+            case "customer": return { vehicle: { customer: { name: dir } } };
             case "serviceDate":
-            default: return [{ startDateTime: { sort: dir, nulls: dir === "desc" ? "last" : "first" } }, { serviceDate: dir }];
+            default: return serviceDateOrderBy(dir);
           }
         })(),
         skip,

+ 15 - 1
src/features/vehicles/Actions/vehicleActions.ts

@@ -76,6 +76,8 @@ export async function getVehiclesPaginated(params: {
   pageSize?: number;
   search?: string;
   archived?: boolean;
+  sortBy?: string;
+  sortOrder?: "asc" | "desc";
 }) {
   return withAuth(async ({ organizationId }) => {
     const page = params.page || 1;
@@ -115,7 +117,19 @@ export async function getVehiclesPaginated(params: {
           customer: { select: { id: true, name: true, company: true } },
           _count: { select: { serviceRecords: true } },
         },
-        orderBy: { updatedAt: "desc" },
+        orderBy: (() => {
+          const dir = params.sortOrder || "desc";
+          switch (params.sortBy) {
+            case "plate": return { licensePlate: { sort: dir, nulls: "last" as const } };
+            // The column shows "year make model" as one value; sort by make,
+            // then model, then year so identical models group together
+            case "vehicle": return [{ make: dir }, { model: dir }, { year: dir }];
+            case "customer": return { customer: { name: dir } };
+            case "mileage": return { mileage: dir };
+            case "services": return { serviceRecords: { _count: dir } };
+            default: return { updatedAt: "desc" as const };
+          }
+        })(),
         skip,
         take: pageSize,
       }),

+ 30 - 0
src/lib/date-sort.ts

@@ -0,0 +1,30 @@
+import { Prisma } from "@/generated/prisma/client";
+
+/**
+ * SQL ORDER BY expression for the date a work order/invoice is presented
+ * with: the explicitly set invoice date, else the scheduled start, else the
+ * service date. Matches what the invoice PDF and list views display.
+ * `alias` must be a code-supplied table alias, never user input.
+ */
+export function effectiveDateSql(alias: string): Prisma.Sql {
+  const a = Prisma.raw(`"${alias}"`);
+  return Prisma.sql`COALESCE(${a}."invoiceDate", ${a}."startDateTime", ${a}."serviceDate")`;
+}
+
+/**
+ * Prisma orderBy approximating COALESCE(startDateTime, serviceDate) for
+ * lists that display the scheduled start when present. Records without a
+ * scheduled start (imported/legacy data) sort by serviceDate and group at
+ * the old end: nulls first ascending, nulls last descending.
+ */
+export function serviceDateOrderBy(dir: "asc" | "desc") {
+  return [
+    {
+      startDateTime: {
+        sort: dir,
+        nulls: dir === "asc" ? ("first" as const) : ("last" as const),
+      },
+    },
+    { serviceDate: dir },
+  ];
+}