From 57f27a0f5c4143790c209be88d31d26b55a04596 Mon Sep 17 00:00:00 2001 From: Adarsh Tiwari Date: Fri, 8 Aug 2025 14:15:16 +0530 Subject: [PATCH 1/5] added mobile logic --- frontend/app/(dashboard)/documents/page.tsx | 33 +++++-- .../app/(dashboard)/equity/dividends/page.tsx | 19 +++- frontend/app/(dashboard)/people/[id]/page.tsx | 4 +- frontend/app/(dashboard)/people/page.tsx | 20 ++++- .../app/(dashboard)/updates/company/page.tsx | 2 +- frontend/components/DataTable.tsx | 88 +++++++++++++++++-- 6 files changed, 149 insertions(+), 17 deletions(-) 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)/people/[id]/page.tsx b/frontend/app/(dashboard)/people/[id]/page.tsx index 844915cb09..e0d79245f7 100644 --- a/frontend/app/(dashboard)/people/[id]/page.tsx +++ b/frontend/app/(dashboard)/people/[id]/page.tsx @@ -701,7 +701,7 @@ function SharesTab({ investorId }: { investorId: string }) { return isLoading ? ( ) : shareHoldings.length > 0 ? ( - + ) : ( This investor does not hold any shares. ); @@ -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,12 @@ 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"), { header: "Status", - meta: { filterOptions: ["Active", "Onboarding", "Alumni"] }, + meta: { filterOptions: precomputedFilterOptions.status }, cell: (info) => info.row.original.endedAt ? ( Ended on {formatDate(info.row.original.endedAt)} @@ -159,6 +174,7 @@ export default function PeoplePage() { } diff --git a/frontend/app/(dashboard)/updates/company/page.tsx b/frontend/app/(dashboard)/updates/company/page.tsx index 19a1cdc0c1..5afee047da 100644 --- a/frontend/app/(dashboard)/updates/company/page.tsx +++ b/frontend/app/(dashboard)/updates/company/page.tsx @@ -140,7 +140,7 @@ const AdminList = ({ onEditUpdate }: { onEditUpdate: (update: UpdateListItem) => return ( <> - onEditUpdate(row)} /> + onEditUpdate(row)} mobileFilterColumn="status" /> setDeletingUpdate(null)}> diff --git a/frontend/components/DataTable.tsx b/frontend/components/DataTable.tsx index 625e3c9f66..2dfd82d5fe 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,9 +163,12 @@ 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; @@ -193,7 +201,7 @@ export default function DataTable({
) : null} + {/* 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 [ + , + // Then render the rest of the filter buttons + ...options.map((option) => { + const isActive = filterValue?.includes(option) ?? false; + return ( + + ); + }), + ]; + })} +
+
+ ) : null} + {data.headers.map((headerGroup) => ( @@ -301,7 +373,9 @@ export default function DataTable({ ({ {row.getVisibleCells().map((cell) => ( cell.column.id === "actions" && e.stopPropagation()} > {typeof cell.column.columnDef.header === "string" && ( From 49c07ae954a0ccb84a8f1e7861501e5cbe819868 Mon Sep 17 00:00:00 2001 From: Adarsh Tiwari Date: Fri, 8 Aug 2025 18:50:31 +0530 Subject: [PATCH 2/5] added more button --- frontend/app/(dashboard)/invoices/page.tsx | 138 +++++++++-- frontend/app/(dashboard)/people/[id]/page.tsx | 2 +- frontend/app/(dashboard)/people/page.tsx | 3 +- .../app/(dashboard)/updates/company/page.tsx | 2 +- frontend/components/DataTable.tsx | 220 +++++++++--------- 5 files changed, 236 insertions(+), 129 deletions(-) diff --git a/frontend/app/(dashboard)/invoices/page.tsx b/frontend/app/(dashboard)/invoices/page.tsx index 14999a9250..f50bff12da 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,49 @@ export default function InvoicesPage() { contractorId: user.roles.administrator ? undefined : user.roles.worker?.id, }); + const precomputedFilterOptions = useMemo(() => { + const statusSet = new Set(); + + for (const invoice of data) { + let label: string; + + // Use the same switch statement logic from InvoiceStatus component + switch (invoice.status) { + case "received": + case "approved": + if (invoice.approvals.length < company.requiredInvoiceApprovals) { + label = "Awaiting approval"; + } else { + label = "Approved"; + } + break; + case "processing": + label = "Payment in progress"; + break; + case "payment_pending": + label = "Payment scheduled"; + break; + case "paid": + label = "Paid"; + break; + case "rejected": + label = "Rejected"; + break; + case "failed": + label = "Failed"; + break; + default: + label = statusNames[invoice.status] || invoice.status; + } + + statusSet.add(label); + } + + return { + status: [...statusSet], + }; + }, [data, company.requiredInvoiceApprovals]); + const { canSubmitInvoices, hasLegalDetails, unsignedContractId } = useCanSubmitInvoices(); const isPayNowDisabled = useCallback( @@ -209,17 +258,43 @@ export default function InvoicesPage() { (value) => (value ? formatMoneyFromCents(value) : "N/A"), "numeric", ), - columnHelper.accessor((row) => statusNames[row.status], { - header: "Status", - cell: (info) => ( -
- -
- ), - meta: { - filterOptions: [...new Set(data.map((invoice) => statusNames[invoice.status]))], + columnHelper.accessor( + (row) => { + switch (row.status) { + case "received": + case "approved": + if (row.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[row.status] || row.status; + } }, - }), + { + id: "status", + header: "Status", + cell: (info) => ( +
+ +
+ ), + meta: { + filterOptions: precomputedFilterOptions.status, + }, + }, + ), columnHelper.accessor(isActionable, { id: "actions", header: () => null, @@ -242,7 +317,7 @@ export default function InvoicesPage() { }, }), ], - [], + [precomputedFilterOptions, company.requiredInvoiceApprovals, user.roles.administrator], ); const handleInvoiceAction = (actionId: string, invoices: Invoice[]) => { @@ -414,23 +489,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 e0d79245f7..4313a5db16 100644 --- a/frontend/app/(dashboard)/people/[id]/page.tsx +++ b/frontend/app/(dashboard)/people/[id]/page.tsx @@ -701,7 +701,7 @@ function SharesTab({ investorId }: { investorId: string }) { return isLoading ? ( ) : shareHoldings.length > 0 ? ( - + ) : ( This investor does not hold any shares. ); diff --git a/frontend/app/(dashboard)/people/page.tsx b/frontend/app/(dashboard)/people/page.tsx index 5bf8746259..df8c130de9 100644 --- a/frontend/app/(dashboard)/people/page.tsx +++ b/frontend/app/(dashboard)/people/page.tsx @@ -128,6 +128,7 @@ export default function PeoplePage() { }), 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: precomputedFilterOptions.status }, cell: (info) => @@ -144,7 +145,7 @@ export default function PeoplePage() { ), }), ], - [], + [precomputedFilterOptions], ); const table = useTable({ diff --git a/frontend/app/(dashboard)/updates/company/page.tsx b/frontend/app/(dashboard)/updates/company/page.tsx index 5afee047da..19a1cdc0c1 100644 --- a/frontend/app/(dashboard)/updates/company/page.tsx +++ b/frontend/app/(dashboard)/updates/company/page.tsx @@ -140,7 +140,7 @@ const AdminList = ({ onEditUpdate }: { onEditUpdate: (update: UpdateListItem) => return ( <> - onEditUpdate(row)} mobileFilterColumn="status" /> + onEditUpdate(row)} /> setDeletingUpdate(null)}> diff --git a/frontend/components/DataTable.tsx b/frontend/components/DataTable.tsx index 2dfd82d5fe..851bd41e1e 100644 --- a/frontend/components/DataTable.tsx +++ b/frontend/components/DataTable.tsx @@ -175,118 +175,124 @@ export default function DataTable({ 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} - {selectable ? ( -
-
- - {selectedRowCount} selected - - + {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)}
- {selectionActions?.(selectedRows)} -
- ) : null} + ) : null} +
+ + {actions ?
{actions}
: null}
-
{actions}
) : null} From b817130123908dc81ee6857c49f1810754f035e7 Mon Sep 17 00:00:00 2001 From: Adarsh Tiwari Date: Fri, 8 Aug 2025 18:56:14 +0530 Subject: [PATCH 3/5] fix --- frontend/app/(dashboard)/invoices/page.tsx | 4 ++-- frontend/app/(dashboard)/people/page.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/(dashboard)/invoices/page.tsx b/frontend/app/(dashboard)/invoices/page.tsx index f50bff12da..11f6fad3a5 100644 --- a/frontend/app/(dashboard)/invoices/page.tsx +++ b/frontend/app/(dashboard)/invoices/page.tsx @@ -353,8 +353,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(), diff --git a/frontend/app/(dashboard)/people/page.tsx b/frontend/app/(dashboard)/people/page.tsx index df8c130de9..a1dd2224ce 100644 --- a/frontend/app/(dashboard)/people/page.tsx +++ b/frontend/app/(dashboard)/people/page.tsx @@ -152,7 +152,7 @@ export default function PeoplePage() { columns, data: workers, initialState: { - sorting: [{ id: "Status", desc: false }], + sorting: [{ id: "status", desc: false }], }, getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), From f144666b3016d6dc5c6ae5f5c1b31bb8baf57894 Mon Sep 17 00:00:00 2001 From: Adarsh Tiwari Date: Sat, 9 Aug 2025 01:55:38 +0530 Subject: [PATCH 4/5] added test suite --- e2e/tests/mobile-filters.spec.ts | 258 +++++++++++++++++++++ frontend/app/(dashboard)/invoices/page.tsx | 108 +++------ 2 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 e2e/tests/mobile-filters.spec.ts 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)/invoices/page.tsx b/frontend/app/(dashboard)/invoices/page.tsx index 11f6fad3a5..f8d22483d2 100644 --- a/frontend/app/(dashboard)/invoices/page.tsx +++ b/frontend/app/(dashboard)/invoices/page.tsx @@ -96,48 +96,41 @@ 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) { - let label: string; - - // Use the same switch statement logic from InvoiceStatus component - switch (invoice.status) { - case "received": - case "approved": - if (invoice.approvals.length < company.requiredInvoiceApprovals) { - label = "Awaiting approval"; - } else { - label = "Approved"; - } - break; - case "processing": - label = "Payment in progress"; - break; - case "payment_pending": - label = "Payment scheduled"; - break; - case "paid": - label = "Paid"; - break; - case "rejected": - label = "Rejected"; - break; - case "failed": - label = "Failed"; - break; - default: - label = statusNames[invoice.status] || invoice.status; - } - + const label = getInvoiceStatusLabel(invoice, company); statusSet.add(label); } return { - status: [...statusSet], + status: [...statusSet].sort(), }; - }, [data, company.requiredInvoiceApprovals]); + }, [data, company, getInvoiceStatusLabel]); const { canSubmitInvoices, hasLegalDetails, unsignedContractId } = useCanSubmitInvoices(); @@ -258,43 +251,18 @@ export default function InvoicesPage() { (value) => (value ? formatMoneyFromCents(value) : "N/A"), "numeric", ), - columnHelper.accessor( - (row) => { - switch (row.status) { - case "received": - case "approved": - if (row.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[row.status] || row.status; - } - }, - { - id: "status", - header: "Status", - cell: (info) => ( -
- -
- ), - meta: { - filterOptions: precomputedFilterOptions.status, - }, + columnHelper.accessor((row) => getInvoiceStatusLabel(row, company), { + id: "status", + header: "Status", + cell: (info) => ( +
+ +
+ ), + meta: { + filterOptions: precomputedFilterOptions.status, }, - ), + }), columnHelper.accessor(isActionable, { id: "actions", header: () => null, @@ -317,7 +285,7 @@ export default function InvoicesPage() { }, }), ], - [precomputedFilterOptions, company.requiredInvoiceApprovals, user.roles.administrator], + [precomputedFilterOptions, company, user.roles.administrator, getInvoiceStatusLabel], ); const handleInvoiceAction = (actionId: string, invoices: Invoice[]) => { From d33e9d1b7b0330ab8fc97f2ac6224ffb1e524dd2 Mon Sep 17 00:00:00 2001 From: Adarsh Tiwari Date: Sat, 9 Aug 2025 01:58:18 +0530 Subject: [PATCH 5/5] test --- .github/workflows/tests.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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