diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37d5595022..c4c50b2588 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,12 +2,12 @@ name: Tests on: push: + workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} RAILS_ENV: test STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -16,7 +16,7 @@ env: jobs: rspec: - runs-on: ubicloud-standard-2 + runs-on: ubuntu-24.04 services: postgres: @@ -65,7 +65,7 @@ jobs: playwright: name: playwright - runs-on: ubicloud-standard-4 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -116,4 +116,4 @@ jobs: with: name: playwright-report path: playwright-report/ - retention-days: 7 + retention-days: 7 \ No newline at end of file diff --git a/e2e/tests/mobile-filters.spec.ts b/e2e/tests/mobile-filters.spec.ts new file mode 100644 index 0000000000..b10d9c81de --- /dev/null +++ b/e2e/tests/mobile-filters.spec.ts @@ -0,0 +1,258 @@ +import { companiesFactory } from "@test/factories/companies"; +import { companyContractorsFactory } from "@test/factories/companyContractors"; +import { documentsFactory } from "@test/factories/documents"; +import { invoicesFactory } from "@test/factories/invoices"; +import { usersFactory } from "@test/factories/users"; +import { login } from "@test/helpers/auth"; +import { expect, test } from "@test/index"; + +test.describe("Mobile filters", () => { + const mobileViewport = { width: 640, height: 800 }; + + test.beforeEach(async ({ page }) => { + await page.setViewportSize(mobileViewport); + }); + + test("administrator can filter invoices using mobile status filter buttons", async ({ page }) => { + // Setup: Create company with admin and invoices with different statuses + const { adminUser, company } = await companiesFactory.createCompletedOnboarding({ + requiredInvoiceApprovalCount: 1, + }); + + // Create invoices with all the different status types according to + await invoicesFactory.create({ companyId: company.id, status: "received" }); + + await invoicesFactory.create({ + companyId: company.id, + status: "approved", + invoiceApprovalsCount: 1, + }); + + // "Payment in progress" status + await invoicesFactory.create({ companyId: company.id, status: "processing" }); + + // "Payment scheduled" status + await invoicesFactory.create({ companyId: company.id, status: "payment_pending" }); + + // "Paid" status + await invoicesFactory.create({ companyId: company.id, status: "paid" }); + + // "Rejected" status + await invoicesFactory.create({ companyId: company.id, status: "rejected" }); + + // "Failed" status + await invoicesFactory.create({ companyId: company.id, status: "failed" }); + + await login(page, adminUser); + await page.goto("/invoices"); + + // Verify the header shows correctly + await expect(page.getByRole("heading", { name: "Invoices", level: 1 })).toBeVisible(); + + // Verify mobile filter buttons are visible - these should match all possible status labels from getInvoiceStatusLabel + await expect(page.getByRole("button", { name: "All", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Awaiting approval" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Paid" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Rejected" })).toBeVisible(); + + // Test filtering by "Awaiting approval" + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("button", { name: "Awaiting approval" }).click(); + // Check that invoices with "Awaiting approval" status are visible (use first() to handle multiple matches) + await expect(page.getByRole("cell", { name: "Awaiting approval" }).first()).toBeVisible(); + // Check that invoices with other statuses are not visible (only check a couple) + await expect(page.getByRole("cell", { name: "Approved" })).not.toBeVisible(); + await expect(page.getByRole("cell", { name: "Paid" })).not.toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + + // Test filtering by "Paid" + await page.getByRole("button", { name: "Paid" }).click(); + // Check that invoices with "Paid" status are visible (use first() to handle multiple matches) + await expect(page.getByRole("cell", { name: "Paid" }).first()).toBeVisible(); + // Check that invoices with other statuses are not visible (only check a couple) + await expect(page.getByRole("cell", { name: "Awaiting approval" })).not.toBeVisible(); + await expect(page.getByRole("cell", { name: "Payment scheduled" })).not.toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + + // Test filtering by "Rejected" + await page.getByRole("button", { name: "Rejected" }).click(); + // Check that invoices with "Rejected" status are visible (use first() to handle multiple matches) + await expect(page.getByRole("cell", { name: "Rejected" }).first()).toBeVisible(); + // Check that invoices with other statuses are not visible (only check a couple) + await expect(page.getByRole("cell", { name: "Awaiting approval" })).not.toBeVisible(); + await expect(page.getByRole("cell", { name: "Failed" })).not.toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + + // Test filtering by "All" again + await page.getByRole("button", { name: "All", exact: true }).click(); + // Check that all statuses are visible in the table when "All" filter is selected + // Only check a few representative statuses (use first() to handle multiple matches) + await expect(page.getByRole("cell", { name: "Awaiting approval" }).first()).toBeVisible(); + await expect(page.getByRole("cell", { name: "Paid" }).first()).toBeVisible(); + }); + + test("administrator can filter people using mobile status filter buttons", async ({ page }) => { + // Setup: Create company with admin and contractors with different statuses + const { adminUser, company } = await companiesFactory.createCompletedOnboarding(); + + // Create contractors with different statuses + // Active contractor (already started) + const { user: activeUser } = await usersFactory.create(); + await companyContractorsFactory.create({ + companyId: company.id, + userId: activeUser.id, + startedAt: new Date(2020, 0, 1), + }); + + // Onboarding contractor (future start date) + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); // 30 days in the future + const { user: onboardingUser } = await usersFactory.create(); + await companyContractorsFactory.create({ + companyId: company.id, + userId: onboardingUser.id, + startedAt: futureDate, + }); + + // Alumni contractor (has end date) + const pastDate = new Date(2020, 0, 1); + const endDate = new Date(2022, 0, 1); + const { user: alumniUser } = await usersFactory.create(); + await companyContractorsFactory.create({ + companyId: company.id, + userId: alumniUser.id, + startedAt: pastDate, + endedAt: endDate, + }); + + await login(page, adminUser); + await page.goto("/people"); + + // Verify mobile filter buttons are visible + await expect(page.getByRole("button", { name: "All", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Active" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Onboarding" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Alumni" })).toBeVisible(); + + // Test filtering by "Active" + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("button", { name: "Active" }).click(); + await expect(page.getByText("Started on")).toBeVisible(); + await expect(page.getByText("Ended on")).not.toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + + // Test filtering by "Alumni" + await page.getByRole("button", { name: "Alumni" }).click(); + await expect(page.getByText("Ended on")).toBeVisible(); + await expect(page.getByText("Started on")).not.toBeVisible(); + + // Test filtering by "All" again + await page.getByRole("button", { name: "All", exact: true }).click(); + await expect(page.getByText("Started on")).toBeVisible(); + await expect(page.getByText("Ended on")).toBeVisible(); + }); + + test("contractor can filter documents using mobile status filter buttons", async ({ page }) => { + // Setup: Create company, contractor and documents + const { company } = await companiesFactory.createCompletedOnboarding(); + const { user } = await usersFactory.create(); + await companyContractorsFactory.create({ companyId: company.id, userId: user.id }); + + // Create documents with different statuses using the factory + + // Signed document + await documentsFactory.create( + { + name: "Signed Document", + companyId: company.id, + }, + { + signatures: [{ userId: user.id, title: "Signer" }], + signed: true, // Makes the document signed + }, + ); + + // Pending document (unsigned) + await documentsFactory.create( + { + name: "Pending Document", + companyId: company.id, + }, + { + signatures: [{ userId: user.id, title: "Signer" }], + signed: false, // Makes the document pending + }, + ); + + // Draft document (no signatures needed) + await documentsFactory.create({ + name: "Draft Document", + companyId: company.id, + }); + + await login(page, user); + await page.goto("/documents"); + + // Verify mobile filter buttons are visible + await expect(page.getByRole("button", { name: "All", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Signature required" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Signed" })).toBeVisible(); + + // Test filtering by "Pending" + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("button", { name: "Signature required" }).click(); + await expect(page.getByText("Pending Document")).toBeVisible(); + await expect(page.getByRole("cell", { name: "Signature required" })).toBeVisible(); + await expect(page.getByText("Signed Document")).not.toBeVisible(); + await page.getByRole("button", { name: "All", exact: true }).click(); + + // Test filtering by "Signed" + await page.getByRole("button", { name: "Signed" }).click(); + await expect(page.getByText("Signed Document")).toBeVisible(); + await expect(page.getByText("Pending Document")).not.toBeVisible(); + + // Test filtering by "All" again + await page.getByRole("button", { name: "All", exact: true }).click(); + await expect(page.getByText("Pending Document")).toBeVisible(); + await expect(page.getByText("Signed Document")).toBeVisible(); + }); + + test("mobile select all and dropdown menu functionality works", async ({ page }) => { + // Setup: Create company with admin and invoices + const { adminUser, company } = await companiesFactory.createCompletedOnboarding({ + requiredInvoiceApprovalCount: 1, + }); + + // Create some invoices + for (let i = 0; i < 3; i++) { + await invoicesFactory.create({ companyId: company.id }); + } + + await login(page, adminUser); + await page.goto("/invoices"); + + // Verify mobile layout elements + await expect(page.getByRole("button", { name: "Select all" })).toBeVisible(); + await expect(page.getByRole("button", { name: "More options" })).toBeVisible(); + + // Test select all functionality + await page.getByRole("button", { name: "Select all" }).click(); + // Check that all checkboxes are selected + await expect(page.getByLabel("Select row").first()).toBeChecked(); + await expect(page.getByText("3 selected")).toBeVisible(); + + // Click again to deselect all + await page.getByRole("button", { name: "Deselect all" }).click(); + await expect(page.getByLabel("Select row").first()).not.toBeChecked(); + + // Test dropdown menu + await page.getByRole("button", { name: "More options" }).click(); + await expect(page.getByRole("menuitem", { name: "Download CSV" })).toBeVisible(); + + // Click on Download CSV option + await page.getByRole("menuitem", { name: "Download CSV" }).click(); + // This would normally trigger a download, but we can't easily verify that in the test + // Instead, verify the menu closed after clicking + await expect(page.getByRole("menuitem", { name: "Download CSV" })).not.toBeVisible(); + }); +}); diff --git a/frontend/app/(dashboard)/documents/page.tsx b/frontend/app/(dashboard)/documents/page.tsx index 500dee8abf..2fbc1ee9e0 100644 --- a/frontend/app/(dashboard)/documents/page.tsx +++ b/frontend/app/(dashboard)/documents/page.tsx @@ -262,6 +262,24 @@ export default function DocumentsPage() { if (downloadUrl) window.location.href = downloadUrl; }, [downloadUrl]); + const precomputedFilterOptions = useMemo(() => { + const typeSet = new Set(); + const dateSet = new Set(); + const statusSet = new Set(); + + for (const doc of documents) { + typeSet.add(typeLabels[doc.type]); + dateSet.add(doc.createdAt.getFullYear().toString()); + statusSet.add(getStatus(doc).name); + } + + return { + type: [...typeSet], + date: [...dateSet], + status: [...statusSet], + }; + }, [documents, typeLabels]); + const columns = useMemo( () => [ @@ -274,21 +292,24 @@ export default function DocumentsPage() { : null, columnHelper.simple("name", "Document"), columnHelper.accessor((row) => typeLabels[row.type], { + id: "documentType", // Explicit ID header: "Type", - meta: { filterOptions: [...new Set(documents.map((document) => typeLabels[document.type]))] }, + meta: { filterOptions: precomputedFilterOptions.type }, }), columnHelper.accessor("createdAt", { + id: "documentDate", // Explicit ID header: "Date", cell: (info) => formatDate(info.getValue()), meta: { - filterOptions: [...new Set(documents.map((document) => document.createdAt.getFullYear().toString()))], + filterOptions: precomputedFilterOptions.date, }, filterFn: (row, _, filterValue) => Array.isArray(filterValue) && filterValue.includes(row.original.createdAt.getFullYear().toString()), }), columnHelper.accessor((row) => getStatus(row).name, { + id: "documentStatus", // Explicit ID header: "Status", - meta: { filterOptions: [...new Set(documents.map((document) => getStatus(document).name))] }, + meta: { filterOptions: precomputedFilterOptions.status }, cell: (info) => { const { variant, text } = getStatus(info.row.original); return {text}; @@ -328,13 +349,14 @@ export default function DocumentsPage() { }, }), ].filter((column) => !!column), - [userId], + [userId, precomputedFilterOptions], ); + const storedColumnFilters = columnFiltersSchema.safeParse( JSON.parse(localStorage.getItem(storageKeys.DOCUMENTS_COLUMN_FILTERS) ?? "{}"), ); const [columnFilters, setColumnFilters] = useState( - storedColumnFilters.data ?? [{ id: "Status", value: ["Signature required"] }], + storedColumnFilters.data ?? [{ id: "documentStatus", value: ["Signature required"] }], ); const table = useTable({ columns, @@ -403,6 +425,7 @@ export default function DocumentsPage() { : undefined} + mobileFilterColumn="documentStatus" {...(isCompanyRepresentative && { searchColumn: "Signer" })} /> {signDocument ? setSignDocumentId(null)} /> : null} diff --git a/frontend/app/(dashboard)/equity/dividends/page.tsx b/frontend/app/(dashboard)/equity/dividends/page.tsx index ba6bf313e6..fce3ddf362 100644 --- a/frontend/app/(dashboard)/equity/dividends/page.tsx +++ b/frontend/app/(dashboard)/equity/dividends/page.tsx @@ -90,6 +90,22 @@ export default function Dividends() { }); const hasLegalDetails = user.legalName && user.address.street_address && user.taxInformationConfirmedAt; + + const precomputedFilterOptions = useMemo(() => { + const statusSet = new Set(); + + for (const dividend of data) { + // Extract status or any other filter options you need + if (dividend.status) { + statusSet.add(dividend.status); + } + } + + return { + status: [...statusSet], + }; + }, [data]); + const columns = useMemo( () => [ columnHelper.simple("dividendRound.issuedAt", "Issue date", formatDate), @@ -102,6 +118,7 @@ export default function Dividends() { columnHelper.simple("netAmountInCents", "Net amount", (value) => formatMoneyFromCents(value ?? 0), "numeric"), columnHelper.accessor("status", { header: "Status", + meta: { filterOptions: precomputedFilterOptions.status }, cell: (info) => (
@@ -151,7 +168,7 @@ export default function Dividends() { {isLoading ? ( ) : data.length > 0 ? ( - + ) : (
You have not been issued any dividends yet. diff --git a/frontend/app/(dashboard)/invoices/page.tsx b/frontend/app/(dashboard)/invoices/page.tsx index 14999a9250..f8d22483d2 100644 --- a/frontend/app/(dashboard)/invoices/page.tsx +++ b/frontend/app/(dashboard)/invoices/page.tsx @@ -13,6 +13,7 @@ import { Download, Eye, Info, + MoreHorizontal, Plus, SquarePen, Trash2, @@ -48,8 +49,13 @@ import TableSkeleton from "@/components/TableSkeleton"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; import { Separator } from "@/components/ui/separator"; import { useCurrentCompany, useCurrentUser } from "@/global"; @@ -90,6 +96,42 @@ export default function InvoicesPage() { contractorId: user.roles.administrator ? undefined : user.roles.worker?.id, }); + const getInvoiceStatusLabel = useCallback((invoice: Invoice, company: { requiredInvoiceApprovals: number }) => { + switch (invoice.status) { + case "received": + case "approved": + if (invoice.approvals.length < company.requiredInvoiceApprovals) { + return "Awaiting approval"; + } + return "Approved"; + case "processing": + return "Payment in progress"; + case "payment_pending": + return "Payment scheduled"; + case "paid": + return "Paid"; + case "rejected": + return "Rejected"; + case "failed": + return "Failed"; + default: + return statusNames[invoice.status] || invoice.status; + } + }, []); + + const precomputedFilterOptions = useMemo(() => { + const statusSet = new Set(); + + for (const invoice of data) { + const label = getInvoiceStatusLabel(invoice, company); + statusSet.add(label); + } + + return { + status: [...statusSet].sort(), + }; + }, [data, company, getInvoiceStatusLabel]); + const { canSubmitInvoices, hasLegalDetails, unsignedContractId } = useCanSubmitInvoices(); const isPayNowDisabled = useCallback( @@ -209,7 +251,8 @@ export default function InvoicesPage() { (value) => (value ? formatMoneyFromCents(value) : "N/A"), "numeric", ), - columnHelper.accessor((row) => statusNames[row.status], { + columnHelper.accessor((row) => getInvoiceStatusLabel(row, company), { + id: "status", header: "Status", cell: (info) => (
@@ -217,7 +260,7 @@ export default function InvoicesPage() {
), meta: { - filterOptions: [...new Set(data.map((invoice) => statusNames[invoice.status]))], + filterOptions: precomputedFilterOptions.status, }, }), columnHelper.accessor(isActionable, { @@ -242,7 +285,7 @@ export default function InvoicesPage() { }, }), ], - [], + [precomputedFilterOptions, company, user.roles.administrator, getInvoiceStatusLabel], ); const handleInvoiceAction = (actionId: string, invoices: Invoice[]) => { @@ -278,8 +321,8 @@ export default function InvoicesPage() { data, getRowId: (invoice) => invoice.id, initialState: { - sorting: [{ id: user.roles.administrator ? "Status" : "invoiceDate", desc: !user.roles.administrator }], - columnFilters: user.roles.administrator ? [{ id: "Status", value: ["Awaiting approval", "Failed"] }] : [], + sorting: [{ id: user.roles.administrator ? "status" : "invoiceDate", desc: !user.roles.administrator }], + columnFilters: user.roles.administrator ? [{ id: "status", value: ["Awaiting approval", "Failed"] }] : [], }, getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -414,23 +457,48 @@ export default function InvoicesPage() { ) : data.length > 0 ? ( <> -
+

{data.length} {pluralize("invoice", data.length)}

- table.toggleAllRowsSelected(checked === true)} - /> +
+ + + {user.roles.administrator ? ( + + + + + + + + + Download CSV + + + + + ) : null} +
diff --git a/frontend/app/(dashboard)/people/[id]/page.tsx b/frontend/app/(dashboard)/people/[id]/page.tsx index 844915cb09..4313a5db16 100644 --- a/frontend/app/(dashboard)/people/[id]/page.tsx +++ b/frontend/app/(dashboard)/people/[id]/page.tsx @@ -734,7 +734,7 @@ function OptionsTab({ investorId, userId }: { investorId: string; userId: string ) : equityGrants.length > 0 ? ( <> - + {selectedEquityGrant ? ( { + const roleSet = new Set(); + + for (const worker of workers) { + if (worker.role) { + roleSet.add(worker.role); + } + } + + return { + role: [...roleSet], + status: ["Active", "Onboarding", "Alumni"], + }; + }, [workers]); + const columnHelper = createColumnHelper<(typeof workers)[number]>(); const columns = useMemo( () => [ @@ -109,12 +124,13 @@ export default function PeoplePage() { columnHelper.accessor("role", { header: "Role", cell: (info) => info.getValue() || "N/A", - meta: { filterOptions: [...new Set(workers.map((worker) => worker.role))] }, + meta: { filterOptions: precomputedFilterOptions.role }, }), columnHelper.simple("user.countryCode", "Country", (v) => v && countries.get(v)), columnHelper.accessor((row) => (row.endedAt ? "Alumni" : row.startedAt > new Date() ? "Onboarding" : "Active"), { + id: "status", header: "Status", - meta: { filterOptions: ["Active", "Onboarding", "Alumni"] }, + meta: { filterOptions: precomputedFilterOptions.status }, cell: (info) => info.row.original.endedAt ? ( Ended on {formatDate(info.row.original.endedAt)} @@ -129,14 +145,14 @@ export default function PeoplePage() { ), }), ], - [], + [precomputedFilterOptions], ); const table = useTable({ columns, data: workers, initialState: { - sorting: [{ id: "Status", desc: false }], + sorting: [{ id: "status", desc: false }], }, getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -159,6 +175,7 @@ export default function PeoplePage() { } diff --git a/frontend/components/DataTable.tsx b/frontend/components/DataTable.tsx index 625e3c9f66..851bd41e1e 100644 --- a/frontend/components/DataTable.tsx +++ b/frontend/components/DataTable.tsx @@ -40,6 +40,7 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/utils"; +import { useIsMobile } from "@/utils/use-mobile"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -92,6 +93,7 @@ interface TableProps { onRowClicked?: ((row: T) => void) | undefined; actions?: React.ReactNode; searchColumn?: string | undefined; + mobileFilterColumn?: string | undefined; // New prop to specify which column to use for mobile filters contextMenuContent?: (context: { row: T; isSelected: boolean; @@ -107,16 +109,18 @@ export default function DataTable({ onRowClicked, actions, searchColumn: searchColumnName, + mobileFilterColumn, contextMenuContent, selectionActions, }: TableProps) { + const isMobile = useIsMobile(); + React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { table.toggleAllRowsSelected(false); } } - window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [table]); @@ -133,6 +137,7 @@ export default function DataTable({ }), [table.getState()], ); + const sortable = !!table.options.getSortedRowModel; const filterable = !!table.options.getFilteredRowModel; const selectable = !!table.options.enableRowSelection; @@ -158,129 +163,202 @@ export default function DataTable({ !numeric && "print:text-wrap", ); }; + const searchColumn = searchColumnName ? table.getColumn(searchColumnName) : null; + const getColumnName = (column: Column) => typeof column.columnDef.header === "string" ? column.columnDef.header : ""; + const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original); const selectedRowCount = selectedRows.length; return (
{filterable || actions ? ( -
-
- {table.options.enableGlobalFilter !== false ? ( -
- - - searchColumn ? searchColumn.setFilterValue(e.target.value) : table.setGlobalFilter(e.target.value) - } - className="w-full pl-8" - placeholder={searchColumn ? `Search by ${getColumnName(searchColumn)}...` : "Search..."} - /> -
- ) : null} - {filterableColumns.length > 0 ? ( - - - - - - {filterableColumns.map((column) => { - const filterValue = filterValueSchema.optional().parse(column.getFilterValue()); - return ( - - -
- {getColumnName(column)} - {Array.isArray(filterValue) && filterValue.length > 0 && ( - - {filterValue.length} - - )} -
-
- - column.setFilterValue(undefined)} - > - All - - {column.columnDef.meta?.filterOptions?.map((option) => ( +
+
+
+ {table.options.enableGlobalFilter !== false ? ( +
+ + + searchColumn ? searchColumn.setFilterValue(e.target.value) : table.setGlobalFilter(e.target.value) + } + className="w-full pl-8" + placeholder={searchColumn ? `Search by ${getColumnName(searchColumn)}...` : "Search..."} + /> +
+ ) : null} + + {filterableColumns.length > 0 ? ( + + + + + + {filterableColumns.map((column) => { + const filterValue = filterValueSchema.optional().parse(column.getFilterValue()); + return ( + + +
+ {getColumnName(column)} + {Array.isArray(filterValue) && filterValue.length > 0 && ( + + {filterValue.length} + + )} +
+
+ - column.setFilterValue( - checked - ? [...(filterValue ?? []), option] - : filterValue && filterValue.length > 1 - ? filterValue.filter((o) => o !== option) - : undefined, - ) - } + checked={!filterValue?.length} + onCheckedChange={() => column.setFilterValue(undefined)} > - {option} + All - ))} - -
- ); - })} - {activeFilterCount > 0 && ( - <> - - table.resetColumnFilters(true)}> - Clear all filters - - - )} -
-
- ) : null} + {column.columnDef.meta?.filterOptions?.map((option) => ( + + column.setFilterValue( + checked + ? [...(filterValue ?? []), option] + : filterValue && filterValue.length > 1 + ? filterValue.filter((o) => o !== option) + : undefined, + ) + } + > + {option} + + ))} + + + ); + })} + {activeFilterCount > 0 && ( + <> + + table.resetColumnFilters(true)}> + Clear all filters + + + )} + + + ) : null} + + {selectable && selectedRowCount > 0 ? ( +
+
+ + {selectedRowCount} selected + + + +
+ {selectionActions?.(selectedRows)} +
+ ) : null} +
+ + {actions ?
{actions}
: null} +
+
+ ) : null} - {selectable ? ( -
-
- - {selectedRowCount} selected - + {/* Mobile Status Filter Buttons */} + {isMobile && filterableColumns.length > 0 ? ( +
+
+ {filterableColumns + .filter( + (column) => + // Use specified column if provided + (mobileFilterColumn && column.id === mobileFilterColumn) || + // Otherwise fallback to any status-related column + (!mobileFilterColumn && + (column.id.toLowerCase().includes("status") || + (typeof column.columnDef.header === "string" && + column.columnDef.header.toLowerCase().includes("status")))), + ) + .map((column) => { + const filterValue = filterValueSchema.optional().parse(column.getFilterValue()); + const options = column.columnDef.meta?.filterOptions || []; + const allFiltersActive = !filterValue || filterValue.length === 0; + // First render an "All" button + return [ -
- {selectionActions?.(selectedRows)} -
- ) : null} + All + , + // Then render the rest of the filter buttons + ...options.map((option) => { + const isActive = filterValue?.includes(option) ?? false; + return ( + + ); + }), + ]; + })}
-
{actions}
) : null} @@ -301,7 +379,9 @@ export default function DataTable({ ({ {row.getVisibleCells().map((cell) => ( cell.column.id === "actions" && e.stopPropagation()} > {typeof cell.column.columnDef.header === "string" && (