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 (
-
+
}
-
+
}
-
+
}
-
+
}
-
+
- {/*
+
}
{"tags" === column && value === "desc" && }
- */}
+
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;