From 5856ce1937eb7c734074d5ec05e3fe29f493124d Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Thu, 5 Dec 2024 09:14:13 +0100 Subject: [PATCH] Customers wip --- .../(app)/(sidebar)/customers/page.tsx | 44 ++++++ .../(sidebar)/customers/search-params.ts | 14 ++ .../src/components/customers-header.tsx | 15 ++ apps/dashboard/src/components/main-menu.tsx | 7 +- .../components/tables/customers/columns.tsx | 123 ++++++++++++++++ .../tables/customers/empty-states.tsx | 57 ++++++++ .../src/components/tables/customers/index.tsx | 64 +++++++++ .../src/components/tables/customers/row.tsx | 33 +++++ .../components/tables/customers/skeleton.tsx | 3 + .../tables/customers/table-header.tsx | 115 +++++++++++++++ .../src/components/tables/customers/table.tsx | 131 ++++++++++++++++++ packages/ui/src/components/icons.tsx | 79 ++++++++++- 12 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/page.tsx create mode 100644 apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/search-params.ts create mode 100644 apps/dashboard/src/components/customers-header.tsx create mode 100644 apps/dashboard/src/components/tables/customers/columns.tsx create mode 100644 apps/dashboard/src/components/tables/customers/empty-states.tsx create mode 100644 apps/dashboard/src/components/tables/customers/index.tsx create mode 100644 apps/dashboard/src/components/tables/customers/row.tsx create mode 100644 apps/dashboard/src/components/tables/customers/skeleton.tsx create mode 100644 apps/dashboard/src/components/tables/customers/table-header.tsx create mode 100644 apps/dashboard/src/components/tables/customers/table.tsx diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/page.tsx new file mode 100644 index 0000000000..aab99f2efb --- /dev/null +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/page.tsx @@ -0,0 +1,44 @@ +import { CustomersHeader } from "@/components/customers-header"; +import { ErrorFallback } from "@/components/error-fallback"; +import { CustomersTable } from "@/components/tables/customers"; +import { CustomersSkeleton } from "@/components/tables/customers/skeleton"; +import type { Metadata } from "next"; +import { ErrorBoundary } from "next/dist/client/components/error-boundary"; +import { Suspense } from "react"; +import { searchParamsCache } from "./search-params"; + +export const metadata: Metadata = { + title: "Customers | Midday", +}; + +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + const { + q: query, + sort, + start, + end, + page, + } = searchParamsCache.parse(searchParams); + + return ( +
+ + + + }> + + + +
+ ); +} diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/search-params.ts b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/search-params.ts new file mode 100644 index 0000000000..323a732be1 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/customers/search-params.ts @@ -0,0 +1,14 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, +} from "nuqs/server"; + +export const searchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(0), + q: parseAsString.withDefault(""), + sort: parseAsArrayOf(parseAsString), + start: parseAsString, + end: parseAsString, +}); diff --git a/apps/dashboard/src/components/customers-header.tsx b/apps/dashboard/src/components/customers-header.tsx new file mode 100644 index 0000000000..1e9d725e9d --- /dev/null +++ b/apps/dashboard/src/components/customers-header.tsx @@ -0,0 +1,15 @@ +import { InvoiceSearchFilter } from "@/components/invoice-search-filter"; +import { getCustomers } from "@midday/supabase/cached-queries"; +import { OpenInvoiceSheet } from "./open-invoice-sheet"; + +export async function CustomersHeader() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/main-menu.tsx b/apps/dashboard/src/components/main-menu.tsx index d81534e3a0..19cd7fafa8 100644 --- a/apps/dashboard/src/components/main-menu.tsx +++ b/apps/dashboard/src/components/main-menu.tsx @@ -24,7 +24,8 @@ const icons = { "/transactions": () => , "/invoices": () => , "/tracker": () => , - "/vault": () => , + "/customers": () => , + "/vault": () => , "/settings": () => , "/apps": () => , "/inbox": () => , @@ -51,6 +52,10 @@ const defaultItems = [ path: "/tracker", name: "Tracker", }, + { + path: "/customers", + name: "Customers", + }, { path: "/vault", name: "Vault", diff --git a/apps/dashboard/src/components/tables/customers/columns.tsx b/apps/dashboard/src/components/tables/customers/columns.tsx new file mode 100644 index 0000000000..7eacb1e5e9 --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/columns.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useCustomerParams } from "@/hooks/use-customer-params"; +import { Avatar, AvatarFallback, AvatarImageNext } from "@midday/ui/avatar"; +import { Button } from "@midday/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@midday/ui/dropdown-menu"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import type { ColumnDef } from "@tanstack/react-table"; +import * as React from "react"; + +export type Customer = { + id: string; + name: string; + customer_name?: string; + website: string; + contact_person?: string; + email: string; + invoices: number; + projects: number; + tags: { id: string; name: string }[]; +}; + +export const columns: ColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + cell: ({ row }) => { + const name = row.original.name ?? row.original.customer_name; + + if (!name) return "-"; + + return ( +
+ + {row.original.website && ( + + )} + + {name?.[0]} + + + {name} +
+ ); + }, + }, + { + header: "Contact person", + accessorKey: "contact_person", + cell: ({ row }) => row.getValue("contact_person") ?? "-", + }, + { + header: "Email", + accessorKey: "email", + cell: ({ row }) => row.getValue("email") ?? "-", + }, + { + header: "Invoices", + accessorKey: "invoices", + cell: ({ row }) => row.getValue("invoices") ?? "-", + }, + { + header: "Projects", + accessorKey: "projects", + cell: ({ row }) => row.getValue("projects") ?? "-", + }, + { + header: "Tags", + accessorKey: "tags", + cell: ({ row }) => row.getValue("tags") ?? "-", + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const { setParams } = useCustomerParams(); + + return ( +
+ + + + + + + + setParams({ + customerId: row.original.id, + }) + } + > + Edit customer + + + + table.options.meta?.deleteCustomer(row.original.id) + } + className="text-[#FF3638]" + > + Delete + + + +
+ ); + }, + }, +]; diff --git a/apps/dashboard/src/components/tables/customers/empty-states.tsx b/apps/dashboard/src/components/tables/customers/empty-states.tsx new file mode 100644 index 0000000000..7cd56c879e --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/empty-states.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useCustomerParams } from "@/hooks/use-customer-params"; +import { Button } from "@midday/ui/button"; + +export function EmptyState() { + const { setParams } = useCustomerParams(); + + return ( +
+
+
+

No customers

+

+ You haven't created any customers yet.
+ Go ahead and create your first one. +

+
+ + +
+
+ ); +} + +export function NoResults() { + const { setParams } = useCustomerParams(); + + return ( +
+
+
+

No results

+

+ Try another search, or adjusting the filters +

+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/tables/customers/index.tsx b/apps/dashboard/src/components/tables/customers/index.tsx new file mode 100644 index 0000000000..184627808c --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/index.tsx @@ -0,0 +1,64 @@ +import { getCustomers } from "@midday/supabase/cached-queries"; +import { EmptyState, NoResults } from "./empty-states"; +import { DataTable } from "./table"; + +type Props = { + page: number; + query?: string | null; + sort?: string[] | null; + start?: string | null; + end?: string | null; +}; + +const pageSize = 25; + +export async function CustomersTable({ query, sort, start, end, page }: Props) { + const filter = { + start, + end, + }; + + async function loadMore({ from, to }: { from: number; to: number }) { + "use server"; + + return getCustomers({ + // to, + // from: from + 1, + // searchQuery: query, + // sort, + // filter, + }); + } + + const { data, meta } = await getCustomers({ + searchQuery: query, + sort, + filter, + to: pageSize, + }); + + const hasNextPage = Boolean( + meta?.count && meta.count / (page + 1) > pageSize, + ); + + if (!data?.length) { + if ( + query?.length || + Object.values(filter).some((value) => value !== null) + ) { + return ; + } + + return ; + } + + return ( + + ); +} diff --git a/apps/dashboard/src/components/tables/customers/row.tsx b/apps/dashboard/src/components/tables/customers/row.tsx new file mode 100644 index 0000000000..c7b7759525 --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/row.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { cn } from "@midday/ui/cn"; +import { TableCell, TableRow } from "@midday/ui/table"; +import { type Row, flexRender } from "@tanstack/react-table"; +import type { Customer } from "./columns"; + +type Props = { + row: Row; + setOpen: (id?: string) => void; +}; + +export function CustomerRow({ row, setOpen }: Props) { + return ( + <> + + {row.getVisibleCells().map((cell, index) => ( + + index !== row.getVisibleCells().length - 1 && setOpen(row.id) + } + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + ); +} diff --git a/apps/dashboard/src/components/tables/customers/skeleton.tsx b/apps/dashboard/src/components/tables/customers/skeleton.tsx new file mode 100644 index 0000000000..693235341f --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/skeleton.tsx @@ -0,0 +1,3 @@ +export function CustomersSkeleton() { + return
CustomersSkeleton
; +} diff --git a/apps/dashboard/src/components/tables/customers/table-header.tsx b/apps/dashboard/src/components/tables/customers/table-header.tsx new file mode 100644 index 0000000000..fd9d526257 --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/table-header.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCustomerParams } from "@/hooks/use-customer-params"; +import { Button } from "@midday/ui/button"; +import { + TableHeader as BaseTableHeader, + TableHead, + TableRow, +} from "@midday/ui/table"; +import { ArrowDown, ArrowUp } from "lucide-react"; + +export function TableHeader() { + const { setParams, sort } = useCustomerParams({ shallow: false }); + const [column, value] = sort || []; + + const createSortQuery = (name: string) => { + const [currentColumn, currentValue] = sort || []; + + if (name === currentColumn) { + if (currentValue === "asc") { + setParams({ sort: [name, "desc"] }); + } else if (currentValue === "desc") { + setParams({ sort: null }); + } else { + setParams({ sort: [name, "asc"] }); + } + } else { + setParams({ sort: [name, "asc"] }); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + Actions + + + ); +} diff --git a/apps/dashboard/src/components/tables/customers/table.tsx b/apps/dashboard/src/components/tables/customers/table.tsx new file mode 100644 index 0000000000..c585584451 --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/table.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { deleteCustomerAction } from "@/actions/delete-customer-action"; +import { useCustomerParams } from "@/hooks/use-customer-params"; +import { Spinner } from "@midday/ui/spinner"; +import { Table, TableBody } from "@midday/ui/table"; +import { + getCoreRowModel, + getFilteredRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useAction } from "next-safe-action/hooks"; +import React, { useEffect, useState } from "react"; +import { useInView } from "react-intersection-observer"; +import { type Customer, columns } from "./columns"; +import { CustomerRow } from "./row"; +import { TableHeader } from "./table-header"; + +type Props = { + data: Customer[]; + loadMore: ({ + from, + to, + }: { + from: number; + to: number; + }) => Promise<{ data: Customer[]; meta: { count: number } }>; + pageSize: number; + hasNextPage: boolean; +}; + +export function DataTable({ + data: initialData, + loadMore, + pageSize, + hasNextPage: initialHasNextPage, +}: Props) { + const [data, setData] = useState(initialData); + const [from, setFrom] = useState(pageSize); + const { ref, inView } = useInView(); + const [hasNextPage, setHasNextPage] = useState(initialHasNextPage); + const { setParams, customerId } = useCustomerParams(); + + const deleteCustomer = useAction(deleteCustomerAction); + + const selectedCustomer = data.find((customer) => customer?.id === customerId); + + const setOpen = (id?: string) => { + if (id) { + setParams({ customerId: id }); + } else { + setParams(null); + } + }; + + const handleDeleteCustomer = (id: string) => { + setData((prev) => { + return prev.filter((item) => item.id !== id); + }); + + deleteCustomer.execute({ id }); + }; + + const table = useReactTable({ + data, + getRowId: ({ id }) => id, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + meta: { + deleteCustomer: handleDeleteCustomer, + }, + }); + + const loadMoreData = async () => { + const formatedFrom = from; + const to = formatedFrom + pageSize * 2; + + try { + const { data, meta } = await loadMore({ + from: formatedFrom, + to, + }); + + setData((prev) => [...prev, ...data]); + setFrom(to); + setHasNextPage(meta.count > to); + } catch { + setHasNextPage(false); + } + }; + + useEffect(() => { + if (inView) { + loadMoreData(); + } + }, [inView]); + + useEffect(() => { + setData(initialData); + }, [initialData]); + + return ( +
+ + + + + {table.getRowModel().rows.map((row) => ( + + ))} + +
+ + {hasNextPage && ( +
+
+ + Loading more... +
+
+ )} + {/* + */} +
+ ); +} diff --git a/packages/ui/src/components/icons.tsx b/packages/ui/src/components/icons.tsx index ac769030a6..5b3f8efb79 100644 --- a/packages/ui/src/components/icons.tsx +++ b/packages/ui/src/components/icons.tsx @@ -185,11 +185,86 @@ export const Icons = { /> ), - Overview: MdBarChart, + Overview: (props: any) => ( + + + + + + + + + ), Apps: MdOutlineApps, Transactions: MdOutlineListAlt, Invoice: MdOutlineDescription, - Files: MdOutlineInventory2, + Vault: (props: any) => ( + + + + + + + + + + + + ), + Customers: (props: any) => ( + + + + + + + + + ), X: FaXTwitter, Discord: PiDiscordLogo, GithubOutline: FiGithub,