From 6b288094364fd34b10ad0b4045d8cc8966fb80f0 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Mon, 9 Dec 2024 18:39:53 +0100 Subject: [PATCH] Feature/customers v1.2 (#344) * Add tags to customers * Add tags to customers --- .../src/actions/create-customer-action.ts | 50 ++++++---- .../customer/create-customer-tag-action.ts | 29 ++++++ .../customer/delete-customer-tag-action.ts | 29 ++++++ apps/dashboard/src/actions/customer/schema.ts | 11 +++ apps/dashboard/src/actions/schema.ts | 9 ++ .../src/components/charts/chart-period.tsx | 8 ++ .../src/components/forms/customer-form.tsx | 98 ++++++++++++++++++- .../components/invoice/customer-details.tsx | 1 + .../dashboard/src/components/invoice/form.tsx | 2 +- apps/dashboard/src/components/open-url.tsx | 2 +- .../sheets/invoice-sheet-content.tsx | 2 +- .../sheets/tracker-update-sheet.tsx | 2 +- .../components/tables/customers/columns.tsx | 32 ++++-- .../src/components/tables/customers/row.tsx | 5 +- .../tables/customers/table-header.tsx | 14 +-- .../components/tables/invoices/columns.tsx | 4 +- packages/events/src/events.ts | 8 ++ packages/supabase/src/queries/index.ts | 10 +- packages/supabase/src/types/db.ts | 61 +++++++++--- 19 files changed, 322 insertions(+), 55 deletions(-) create mode 100644 apps/dashboard/src/actions/customer/create-customer-tag-action.ts create mode 100644 apps/dashboard/src/actions/customer/delete-customer-tag-action.ts create mode 100644 apps/dashboard/src/actions/customer/schema.ts diff --git a/apps/dashboard/src/actions/create-customer-action.ts b/apps/dashboard/src/actions/create-customer-action.ts index a7fac15842..8e1cfc9e7a 100644 --- a/apps/dashboard/src/actions/create-customer-action.ts +++ b/apps/dashboard/src/actions/create-customer-action.ts @@ -15,25 +15,37 @@ export const createCustomerAction = authActionClient channel: LogEvents.CreateCustomer.channel, }, }) - .action(async ({ parsedInput: input, ctx: { user, supabase } }) => { - const token = await generateToken(user.id); + .action( + async ({ parsedInput: { tags, ...input }, ctx: { user, supabase } }) => { + const token = await generateToken(user.id); - const { data } = await supabase - .from("customers") - .upsert( - { - ...input, - token, - team_id: user.team_id, - }, - { - onConflict: "id", - }, - ) - .select("id, name") - .single(); + const { data } = await supabase + .from("customers") + .upsert( + { + ...input, + token, + team_id: user.team_id, + }, + { + onConflict: "id", + }, + ) + .select("id, name") + .single(); - revalidateTag(`customers_${user.team_id}`); + if (tags?.length) { + await supabase.from("customer_tags").insert( + tags.map((tag) => ({ + tag_id: tag.id, + customer_id: data?.id, + team_id: user.team_id!, + })), + ); + } - return data; - }); + revalidateTag(`customers_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/customer/create-customer-tag-action.ts b/apps/dashboard/src/actions/customer/create-customer-tag-action.ts new file mode 100644 index 0000000000..a91b5acf1a --- /dev/null +++ b/apps/dashboard/src/actions/customer/create-customer-tag-action.ts @@ -0,0 +1,29 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { revalidateTag } from "next/cache"; +import { authActionClient } from "../safe-action"; +import { createCustomerTagSchema } from "./schema"; + +export const createCustomerTagAction = authActionClient + .schema(createCustomerTagSchema) + .metadata({ + name: "create-customer-tag", + track: { + event: LogEvents.CreateCustomerTag.name, + channel: LogEvents.CreateCustomerTag.channel, + }, + }) + .action( + async ({ parsedInput: { tagId, customerId }, ctx: { user, supabase } }) => { + const { data } = await supabase.from("customer_tags").insert({ + tag_id: tagId, + customer_id: customerId, + team_id: user.team_id!, + }); + + revalidateTag(`customers_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/customer/delete-customer-tag-action.ts b/apps/dashboard/src/actions/customer/delete-customer-tag-action.ts new file mode 100644 index 0000000000..49c01fc1a3 --- /dev/null +++ b/apps/dashboard/src/actions/customer/delete-customer-tag-action.ts @@ -0,0 +1,29 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { revalidateTag } from "next/cache"; +import { authActionClient } from "../safe-action"; +import { deleteCustomerTagSchema } from "./schema"; + +export const deleteCustomerTagAction = authActionClient + .schema(deleteCustomerTagSchema) + .metadata({ + name: "delete-customer-tag", + track: { + event: LogEvents.DeleteCustomerTag.name, + channel: LogEvents.DeleteCustomerTag.channel, + }, + }) + .action( + async ({ parsedInput: { tagId, customerId }, ctx: { user, supabase } }) => { + const { data } = await supabase + .from("customer_tags") + .delete() + .eq("customer_id", customerId) + .eq("tag_id", tagId); + + revalidateTag(`customers_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/customer/schema.ts b/apps/dashboard/src/actions/customer/schema.ts new file mode 100644 index 0000000000..60c58809c2 --- /dev/null +++ b/apps/dashboard/src/actions/customer/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const deleteCustomerTagSchema = z.object({ + tagId: z.string(), + customerId: z.string(), +}); + +export const createCustomerTagSchema = z.object({ + tagId: z.string(), + customerId: z.string(), +}); diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index a03e0bba1e..9c01bce69b 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -606,6 +606,15 @@ export const createCustomerSchema = z.object({ website: z.string().nullable().optional(), phone: z.string().nullable().optional(), contact: z.string().nullable().optional(), + tags: z + .array( + z.object({ + id: z.string().uuid(), + value: z.string(), + }), + ) + .optional() + .nullable(), }); export const inboxUploadSchema = z.array( diff --git a/apps/dashboard/src/components/charts/chart-period.tsx b/apps/dashboard/src/components/charts/chart-period.tsx index f149c7a03d..804e2ec26b 100644 --- a/apps/dashboard/src/components/charts/chart-period.tsx +++ b/apps/dashboard/src/components/charts/chart-period.tsx @@ -44,6 +44,14 @@ const periods = [ to: new Date(), }, }, + { + value: "6m", + label: "Last 6 months", + range: { + from: subMonths(new Date(), 6), + to: new Date(), + }, + }, { value: "12m", label: "Last 12 months", diff --git a/apps/dashboard/src/components/forms/customer-form.tsx b/apps/dashboard/src/components/forms/customer-form.tsx index 0fa1eeccfe..39e4c49bb8 100644 --- a/apps/dashboard/src/components/forms/customer-form.tsx +++ b/apps/dashboard/src/components/forms/customer-form.tsx @@ -1,6 +1,8 @@ "use client"; import { createCustomerAction } from "@/actions/create-customer-action"; +import { createCustomerTagAction } from "@/actions/customer/create-customer-tag-action"; +import { deleteCustomerTagAction } from "@/actions/customer/delete-customer-tag-action"; import { useCustomerParams } from "@/hooks/use-customer-params"; import { useInvoiceParams } from "@/hooks/use-invoice-params"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -14,12 +16,14 @@ import { Button } from "@midday/ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@midday/ui/form"; import { Input } from "@midday/ui/input"; +import { Label } from "@midday/ui/label"; import { SubmitButton } from "@midday/ui/submit-button"; import { Textarea } from "@midday/ui/textarea"; import { useAction } from "next-safe-action/hooks"; @@ -32,6 +36,7 @@ import { type AddressDetails, SearchAddressInput, } from "../search-address-input"; +import { SelectTags } from "../select-tags"; import { VatNumberInput } from "../vat-number-input"; const formSchema = z.object({ @@ -58,6 +63,15 @@ const formSchema = z.object({ zip: z.string().nullable().optional(), vat_number: z.string().nullable().optional(), note: z.string().nullable().optional(), + tags: z + .array( + z.object({ + id: z.string().uuid(), + value: z.string(), + }), + ) + .optional() + .nullable(), }); const excludedDomains = [ @@ -86,6 +100,11 @@ export function CustomerForm({ data }: Props) { const { setParams: setCustomerParams, name } = useCustomerParams(); const { setParams: setInvoiceParams } = useInvoiceParams(); + const deleteCustomerTag = useAction(deleteCustomerTagAction); + const createCustomerTag = useAction(createCustomerTagAction); + + const isEdit = !!data; + const createCustomer = useAction(createCustomerAction, { onSuccess: ({ data }) => { if (data) { @@ -112,13 +131,22 @@ export function CustomerForm({ data }: Props) { note: undefined, phone: undefined, contact: undefined, + tags: undefined, }, }); useEffect(() => { if (data) { setSections(["general", "details"]); - form.reset(data); + form.reset({ + ...data, + tags: + data.tags?.map((tag) => ({ + id: tag.tag?.id ?? "", + value: tag.tag?.name ?? "", + label: tag.tag?.name ?? "", + })) ?? undefined, + }); } }, [data]); @@ -392,6 +420,72 @@ export function CustomerForm({ data }: Props) { /> +
+ + + { + deleteCustomerTag.execute({ + tagId: tag.id, + customerId: form.getValues("id")!, + }); + }} + // Only for create customers + onCreate={(tag) => { + if (!isEdit) { + form.setValue( + "tags", + [ + ...(form.getValues("tags") ?? []), + { + value: tag.value ?? "", + id: tag.id ?? "", + }, + ], + { + shouldDirty: true, + shouldValidate: true, + }, + ); + } + }} + // Only for edit customers + onSelect={(tag) => { + if (isEdit) { + createCustomerTag.execute({ + tagId: tag.id, + customerId: form.getValues("id")!, + }); + } else { + form.setValue( + "tags", + [ + ...(form.getValues("tags") ?? []), + { + value: tag.value ?? "", + id: tag.id ?? "", + }, + ], + { + shouldDirty: true, + shouldValidate: true, + }, + ); + } + }} + /> + + + Tags help categorize and track customer expenses. + +
+
- {data ? "Update" : "Create"} + {isEdit ? "Update" : "Create"}
diff --git a/apps/dashboard/src/components/invoice/customer-details.tsx b/apps/dashboard/src/components/invoice/customer-details.tsx index 34b541c74a..467411a539 100644 --- a/apps/dashboard/src/components/invoice/customer-details.tsx +++ b/apps/dashboard/src/components/invoice/customer-details.tsx @@ -26,6 +26,7 @@ export interface Customer { vat?: string; contact?: string; website?: string; + tags?: { tag: { id: string; name: string } }[]; } interface CustomerDetailsProps { diff --git a/apps/dashboard/src/components/invoice/form.tsx b/apps/dashboard/src/components/invoice/form.tsx index e6b329c805..4a974bfb6e 100644 --- a/apps/dashboard/src/components/invoice/form.tsx +++ b/apps/dashboard/src/components/invoice/form.tsx @@ -162,7 +162,7 @@ export function Form({ teamId, customers, onSubmit, isSubmitting }: Props) { <> {(draftInvoice.isPending || lastEditedText) && -} diff --git a/apps/dashboard/src/components/open-url.tsx b/apps/dashboard/src/components/open-url.tsx index f7c14b5992..e01cd166bd 100644 --- a/apps/dashboard/src/components/open-url.tsx +++ b/apps/dashboard/src/components/open-url.tsx @@ -11,7 +11,7 @@ export function OpenURL({ }: { href: string; children: React.ReactNode; className?: string }) { const handleOnClick = () => { if (isDesktopApp()) { - platform.os.openURL(`${window.location.origin}/${href}`); + platform.os.openURL(href); } else { window.open(href, "_blank"); } diff --git a/apps/dashboard/src/components/sheets/invoice-sheet-content.tsx b/apps/dashboard/src/components/sheets/invoice-sheet-content.tsx index 0c3ce317a2..824da5380c 100644 --- a/apps/dashboard/src/components/sheets/invoice-sheet-content.tsx +++ b/apps/dashboard/src/components/sheets/invoice-sheet-content.tsx @@ -88,7 +88,7 @@ export function InvoiceSheetContent({
- + diff --git a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx index 000548d6f5..bbba6e59b3 100644 --- a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx @@ -174,7 +174,7 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { - + [] = [ @@ -96,11 +98,29 @@ export const columns: ColumnDef[] = [ return "-"; }, }, - // { - // header: "Tags", - // accessorKey: "tags", - // cell: ({ row }) => row.getValue("tags") ?? "-", - // }, + { + header: "Tags", + accessorKey: "tags", + cell: ({ row }) => { + return ( +
+ +
+ {row.original.tags?.map(({ tag }) => ( + + {tag.name} + + ))} +
+ + +
+ +
+
+ ); + }, + }, { id: "actions", header: "Actions", diff --git a/apps/dashboard/src/components/tables/customers/row.tsx b/apps/dashboard/src/components/tables/customers/row.tsx index 7ede79a08f..725d5ee158 100644 --- a/apps/dashboard/src/components/tables/customers/row.tsx +++ b/apps/dashboard/src/components/tables/customers/row.tsx @@ -14,13 +14,14 @@ export function CustomerRow({ row, setOpen }: Props) { return ( <> {row.getVisibleCells().map((cell, index) => ( ![3, 4, 5].includes(index) && setOpen(row.id)} + onClick={() => ![3, 4, 6].includes(index) && setOpen(row.id)} + className={cn(index !== 0 && "hidden md:table-cell")} > {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/apps/dashboard/src/components/tables/customers/table-header.tsx b/apps/dashboard/src/components/tables/customers/table-header.tsx index db5e01bfce..a4ef58dade 100644 --- a/apps/dashboard/src/components/tables/customers/table-header.tsx +++ b/apps/dashboard/src/components/tables/customers/table-header.tsx @@ -32,7 +32,7 @@ export function TableHeader() { return ( - + - + - + - + - + - */} + Actions diff --git a/apps/dashboard/src/components/tables/invoices/columns.tsx b/apps/dashboard/src/components/tables/invoices/columns.tsx index df2771a951..21f1d15f78 100644 --- a/apps/dashboard/src/components/tables/invoices/columns.tsx +++ b/apps/dashboard/src/components/tables/invoices/columns.tsx @@ -224,7 +224,9 @@ export const columns: ColumnDef[] = [ )} - + Open invoice diff --git a/packages/events/src/events.ts b/packages/events/src/events.ts index b4d659ddbb..41242c790b 100644 --- a/packages/events/src/events.ts +++ b/packages/events/src/events.ts @@ -247,4 +247,12 @@ export const LogEvents = { name: "Inbox Upload", channel: "inbox", }, + DeleteCustomerTag: { + name: "Delete Customer Tag", + channel: "customer", + }, + CreateCustomerTag: { + name: "Create Customer Tag", + channel: "customer", + }, }; diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index 90ba59794e..8b328c2184 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -1284,7 +1284,9 @@ export async function getCustomersQuery( const query = supabase .from("customers") - .select("*, invoices:invoices(id), projects:tracker_projects(id)") + .select( + "*, invoices:invoices(id), projects:tracker_projects(id), tags:customer_tags(id, tag:tags(id, name))", + ) .eq("team_id", teamId) .range(from, to); @@ -1318,7 +1320,11 @@ export async function getCustomersQuery( } export async function getCustomerQuery(supabase: Client, customerId: string) { - return supabase.from("customers").select("*").eq("id", customerId).single(); + return supabase + .from("customers") + .select("*, tags:customer_tags(id, tag:tags(id, name))") + .eq("id", customerId) + .single(); } export async function getInvoiceTemplatesQuery( diff --git a/packages/supabase/src/types/db.ts b/packages/supabase/src/types/db.ts index 516b6f8eee..687871d7de 100644 --- a/packages/supabase/src/types/db.ts +++ b/packages/supabase/src/types/db.ts @@ -195,11 +195,58 @@ export type Database = { }, ]; }; + customer_tags: { + Row: { + created_at: string; + customer_id: string; + id: string; + tag_id: string; + team_id: string; + }; + Insert: { + created_at?: string; + customer_id: string; + id?: string; + tag_id: string; + team_id: string; + }; + Update: { + created_at?: string; + customer_id?: string; + id?: string; + tag_id?: string; + team_id?: string; + }; + Relationships: [ + { + foreignKeyName: "customer_tags_customer_id_fkey"; + columns: ["customer_id"]; + isOneToOne: false; + referencedRelation: "customers"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "customer_tags_tag_id_fkey"; + columns: ["tag_id"]; + isOneToOne: false; + referencedRelation: "tags"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "customer_tags_team_id_fkey"; + columns: ["team_id"]; + isOneToOne: false; + referencedRelation: "teams"; + referencedColumns: ["id"]; + }, + ]; + }; customers: { Row: { address_line_1: string | null; address_line_2: string | null; city: string | null; + contact: string | null; country: string | null; country_code: string | null; created_at: string; @@ -219,6 +266,7 @@ export type Database = { address_line_1?: string | null; address_line_2?: string | null; city?: string | null; + contact?: string | null; country?: string | null; country_code?: string | null; created_at?: string; @@ -238,6 +286,7 @@ export type Database = { address_line_1?: string | null; address_line_2?: string | null; city?: string | null; + contact?: string | null; country?: string | null; country_code?: string | null; created_at?: string; @@ -2098,18 +2147,6 @@ export type Database = { logo_url: string; }[]; }; - get_team_bank_accounts_balances_v2: { - Args: { - team_id: string; - }; - Returns: { - id: string; - currency: string; - balance: number; - name: string; - logo_url: string; - }[]; - }; get_total_balance: { Args: { team_id: string;