diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index a607397066..a03e0bba1e 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -605,6 +605,7 @@ export const createCustomerSchema = z.object({ note: z.string().nullable().optional(), website: z.string().nullable().optional(), phone: z.string().nullable().optional(), + contact: z.string().nullable().optional(), }); export const inboxUploadSchema = z.array( 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..48f6023f44 --- /dev/null +++ b/apps/dashboard/src/components/customers-header.tsx @@ -0,0 +1,14 @@ +import { OpenCustomerSheet } from "./open-customer-sheet"; +import { SearchField } from "./search-field"; + +export async function CustomersHeader() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/forms/customer-form.tsx b/apps/dashboard/src/components/forms/customer-form.tsx index ffdf8cadda..0fa1eeccfe 100644 --- a/apps/dashboard/src/components/forms/customer-form.tsx +++ b/apps/dashboard/src/components/forms/customer-form.tsx @@ -48,6 +48,7 @@ const formSchema = z.object({ .nullable() .optional() .transform((url) => url?.replace(/^https?:\/\//, "")), + contact: z.string().nullable().optional(), address_line_1: z.string().nullable().optional(), address_line_2: z.string().nullable().optional(), city: z.string().nullable().optional(), @@ -110,6 +111,7 @@ export function CustomerForm({ data }: Props) { vat_number: undefined, note: undefined, phone: undefined, + contact: undefined, }, }); @@ -237,6 +239,25 @@ export function CustomerForm({ data }: Props) { )} /> + + ( + + + Contact person + + + + + + )} + /> diff --git a/apps/dashboard/src/components/invoice/customer-details.tsx b/apps/dashboard/src/components/invoice/customer-details.tsx index cd32b5e505..34b541c74a 100644 --- a/apps/dashboard/src/components/invoice/customer-details.tsx +++ b/apps/dashboard/src/components/invoice/customer-details.tsx @@ -24,6 +24,8 @@ export interface Customer { zip?: string; country?: string; vat?: string; + contact?: string; + website?: string; } interface CustomerDetailsProps { 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/open-customer-sheet.tsx b/apps/dashboard/src/components/open-customer-sheet.tsx new file mode 100644 index 0000000000..7f540c3548 --- /dev/null +++ b/apps/dashboard/src/components/open-customer-sheet.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useCustomerParams } from "@/hooks/use-customer-params"; +import { useInvoiceParams } from "@/hooks/use-invoice-params"; +import { Button } from "@midday/ui/button"; +import { Icons } from "@midday/ui/icons"; + +export function OpenCustomerSheet() { + const { setParams } = useCustomerParams(); + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/sheets/customer-edit-sheet.tsx b/apps/dashboard/src/components/sheets/customer-edit-sheet.tsx index 5df96c8fd3..d50c01472b 100644 --- a/apps/dashboard/src/components/sheets/customer-edit-sheet.tsx +++ b/apps/dashboard/src/components/sheets/customer-edit-sheet.tsx @@ -54,7 +54,11 @@ export function CustomerEditSheet() { } } - fetchCustomer(); + if (customerId) { + fetchCustomer(); + } else { + setCustomer(null); + } }, [customerId, supabase]); return ( 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..cbd91c02ec --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/columns.tsx @@ -0,0 +1,144 @@ +"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 Link from "next/link"; +import * as React from "react"; + +export type Customer = { + id: string; + name: string; + customer_name?: string; + website: string; + contact?: string; + email: string; + invoices: { id: string }[]; + projects: { id: string }[]; + 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", + cell: ({ row }) => row.getValue("contact") ?? "-", + }, + { + header: "Email", + accessorKey: "email", + cell: ({ row }) => row.getValue("email") ?? "-", + }, + { + header: "Invoices", + accessorKey: "invoices", + cell: ({ row }) => { + if (row.original.invoices.length > 0) { + return ( + + {row.original.invoices.length} + + ); + } + + return "-"; + }, + }, + { + header: "Projects", + accessorKey: "projects", + cell: ({ row }) => { + if (row.original.projects.length > 0) { + return ( + + {row.original.projects.length} + + ); + } + + return "-"; + }, + }, + // { + // header: "Tags", + // accessorKey: "tags", + // cell: ({ row }) => row.getValue("tags") ?? "-", + // }, + { + id: "actions", + header: "Actions", + cell: ({ row, table }) => { + 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..51ec4d718f --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/index.tsx @@ -0,0 +1,62 @@ +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, + }); + } + + const { data, meta } = await getCustomers({ + searchQuery: query, + sort, + 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..7ede79a08f --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/row.tsx @@ -0,0 +1,31 @@ +"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) => ( + ![3, 4, 5].includes(index) && 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..cfcf13dd74 --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/skeleton.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from "@midday/ui/skeleton"; +import { Table, TableBody, TableCell, TableRow } from "@midday/ui/table"; +import { TableHeader } from "./table-header"; + +export function CustomersSkeleton() { + return ( + + + + {Array.from({ length: 25 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+ ); +} 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..db5e01bfce --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/table-header.tsx @@ -0,0 +1,111 @@ +"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..d3744c8cac --- /dev/null +++ b/apps/dashboard/src/components/tables/customers/table.tsx @@ -0,0 +1,123 @@ +"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 } = useCustomerParams(); + + const deleteCustomer = useAction(deleteCustomerAction); + + 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/apps/dashboard/src/hooks/use-customer-params.ts b/apps/dashboard/src/hooks/use-customer-params.ts index 093c1feec7..0fc9a551bb 100644 --- a/apps/dashboard/src/hooks/use-customer-params.ts +++ b/apps/dashboard/src/hooks/use-customer-params.ts @@ -1,11 +1,21 @@ -import { parseAsBoolean, parseAsString, useQueryStates } from "nuqs"; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsString, + useQueryStates, +} from "nuqs"; -export function useCustomerParams() { - const [params, setParams] = useQueryStates({ - customerId: parseAsString, - createCustomer: parseAsBoolean, - name: parseAsString, - }); +export function useCustomerParams(options?: { shallow: boolean }) { + const [params, setParams] = useQueryStates( + { + customerId: parseAsString, + createCustomer: parseAsBoolean, + sort: parseAsArrayOf(parseAsString), + name: parseAsString, + q: parseAsString, + }, + options, + ); return { ...params, diff --git a/bun.lockb b/bun.lockb index 66c491a806..1b7e34b07e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/supabase/src/queries/cached-queries.ts b/packages/supabase/src/queries/cached-queries.ts index 05789199d2..1af903c95a 100644 --- a/packages/supabase/src/queries/cached-queries.ts +++ b/packages/supabase/src/queries/cached-queries.ts @@ -6,6 +6,7 @@ import { createClient } from "../client/server"; import { type GetBurnRateQueryParams, type GetCategoriesParams, + type GetCustomersQueryParams, type GetExpensesQueryParams, type GetInvoiceSummaryParams, type GetInvoicesQueryParams, @@ -500,7 +501,9 @@ export const getPaymentStatus = async () => { )(); }; -export const getCustomers = async () => { +export const getCustomers = async ( + params?: Omit, +) => { const supabase = createClient(); const user = await getUser(); const teamId = user?.data?.team_id; @@ -511,14 +514,14 @@ export const getCustomers = async () => { return unstable_cache( async () => { - return getCustomersQuery(supabase, teamId); + return getCustomersQuery(supabase, { ...params, teamId }); }, ["customers", teamId], { tags: [`customers_${teamId}`], revalidate: 3600, }, - )(); + )(params); }; export const getInvoices = async ( diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index 8e94607c58..90ba59794e 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -1268,13 +1268,53 @@ export async function getPaymentStatusQuery(supabase: Client, teamId: string) { .single(); } -export async function getCustomersQuery(supabase: Client, teamId: string) { - return supabase +export type GetCustomersQueryParams = { + teamId: string; + from?: number; + to?: number; + searchQuery?: string | null; + sort?: string[] | null; +}; + +export async function getCustomersQuery( + supabase: Client, + params: GetCustomersQueryParams, +) { + const { teamId, from = 0, to = 100, searchQuery, sort } = params; + + const query = supabase .from("customers") - .select("*") + .select("*, invoices:invoices(id), projects:tracker_projects(id)") .eq("team_id", teamId) - .order("created_at", { ascending: false }) - .limit(100); + .range(from, to); + + if (searchQuery) { + query.ilike("name", `%${searchQuery}%`); + } + + if (sort) { + const [column, value] = sort; + const ascending = value === "asc"; + + if (column === "invoices") { + query.order("invoices(id)", { ascending }); + } else if (column === "projects") { + query.order("projects(id)", { ascending }); + } else { + query.order(column, { ascending }); + } + } else { + query.order("created_at", { ascending: false }); + } + + const { data, count } = await query; + + return { + meta: { + count, + }, + data, + }; } export async function getCustomerQuery(supabase: Client, customerId: string) { diff --git a/packages/ui/src/components/icons.tsx b/packages/ui/src/components/icons.tsx index ac769030a6..9ee438c80e 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,