From 82fd78be35197ad22321f1aaf7aaf7b185e2ffd5 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Sun, 17 Nov 2024 10:39:12 +0100 Subject: [PATCH 1/8] wip --- .../src/actions/create-tags-action.ts | 23 + apps/dashboard/src/actions/schema.ts | 2 + .../components/forms/tracker-project-form.tsx | 14 + apps/dashboard/src/components/select-tags.tsx | 64 ++ .../src/components/transaction-details.tsx | 11 +- packages/events/src/events.ts | 4 + .../supabase/src/queries/cached-queries.ts | 22 + packages/supabase/src/queries/index.ts | 11 + packages/ui/package.json | 2 + packages/ui/src/components/badge.tsx | 36 + .../ui/src/components/multiple-selector.tsx | 635 ++++++++++++++++++ 11 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/actions/create-tags-action.ts create mode 100644 apps/dashboard/src/components/select-tags.tsx create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/multiple-selector.tsx diff --git a/apps/dashboard/src/actions/create-tags-action.ts b/apps/dashboard/src/actions/create-tags-action.ts new file mode 100644 index 0000000000..6e39db339b --- /dev/null +++ b/apps/dashboard/src/actions/create-tags-action.ts @@ -0,0 +1,23 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { authActionClient } from "./safe-action"; +import { createTagsSchema } from "./schema"; + +export const createTagsAction = authActionClient + .schema(createTagsSchema) + .metadata({ + name: "create-tags", + track: { + event: LogEvents.CreateTag.name, + channel: LogEvents.CreateTag.channel, + }, + }) + .action(async ({ parsedInput: tags, ctx: { user, supabase } }) => { + const { data, error } = await supabase + .from("transaction_tags") + .insert(tags.map((tag) => ({ name: tag.name, team_id: user.team_id }))); + + console.log(error); + return data; + }); diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index db112e9482..af70ae266b 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -13,6 +13,8 @@ export const updateUserSchema = z.object({ revalidatePath: z.string().optional(), }); +export const createTagsSchema = z.array(z.object({ name: z.string() })); + export type UpdateUserFormValues = z.infer; export const trackingConsentSchema = z.boolean(); diff --git a/apps/dashboard/src/components/forms/tracker-project-form.tsx b/apps/dashboard/src/components/forms/tracker-project-form.tsx index 8958890ca3..e52826fde4 100644 --- a/apps/dashboard/src/components/forms/tracker-project-form.tsx +++ b/apps/dashboard/src/components/forms/tracker-project-form.tsx @@ -15,6 +15,7 @@ import { FormMessage, } from "@midday/ui/form"; import { Input } from "@midday/ui/input"; +import { Label } from "@midday/ui/label"; import { Select, SelectContent, @@ -27,6 +28,7 @@ import { Textarea } from "@midday/ui/textarea"; import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { SearchCustomer } from "../search-customer"; +import { SelectTags } from "../select-tags"; type Props = { onSubmit: (data: any) => void; @@ -94,6 +96,18 @@ export function TrackerProjectForm({ )} /> +
+ + + + + + Tags help categorize and track project expenses. + +
+ (tags ?? []); + const createTags = useAction(createTagsAction); + const supabase = createClient(); + const { team_id: teamId } = useUserContext((state) => state.data); + + useEffect(() => { + async function fetchData() { + setIsLoading(true); + + const { data } = await getTransactionTagsQuery(supabase, teamId); + + if (data?.length) { + setData( + data.map((tag) => ({ + label: tag.name, + value: tag.name, + id: tag.id, + })), + ); + } + + setIsLoading(false); + } + + if (!data?.length) { + fetchData(); + } + }, [teamId]); + + return ( +
+ no results found.

} + onChange={(options) => { + const newTags = options.filter((option) => option.create); + + console.log(newTags); + + if (newTags.length > 0) { + createTags.execute(newTags.map((tag) => ({ name: tag.value }))); + } + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/transaction-details.tsx b/apps/dashboard/src/components/transaction-details.tsx index 0a3512676e..7afe1c540b 100644 --- a/apps/dashboard/src/components/transaction-details.tsx +++ b/apps/dashboard/src/components/transaction-details.tsx @@ -38,12 +38,12 @@ import { Attachments } from "./attachments"; import { FormatAmount } from "./format-amount"; import { Note } from "./note"; import { SelectCategory } from "./select-category"; +import { SelectTags } from "./select-tags"; import { TransactionBankAccount } from "./transaction-bank-account"; type Props = { data: any; ids?: string[]; - locale: string; updateTransaction: ( values: UpdateTransactionValues, optimisticData: any, @@ -53,7 +53,6 @@ type Props = { export function TransactionDetails({ data: initialData, ids, - locale, updateTransaction, }: Props) { const [data, setData] = useState(initialData); @@ -313,6 +312,14 @@ export function TransactionDetails({ +
+ + + +
+ Attachment diff --git a/packages/events/src/events.ts b/packages/events/src/events.ts index b2e33a12b5..6a6bdf9f75 100644 --- a/packages/events/src/events.ts +++ b/packages/events/src/events.ts @@ -215,4 +215,8 @@ export const LogEvents = { name: "Delete Customer", channel: "customer", }, + CreateTag: { + name: "Create Tag", + channel: "tag", + }, }; diff --git a/packages/supabase/src/queries/cached-queries.ts b/packages/supabase/src/queries/cached-queries.ts index 8ce545d9f9..efd5221863 100644 --- a/packages/supabase/src/queries/cached-queries.ts +++ b/packages/supabase/src/queries/cached-queries.ts @@ -38,6 +38,7 @@ import { getTeamsByUserIdQuery, getTrackerProjectsQuery, getTrackerRecordsByRangeQuery, + getTransactionTagsQuery, getTransactionsQuery, getUserInvitesQuery, getUserQuery, @@ -579,3 +580,24 @@ export const getLastInvoiceNumber = async () => { }, )(); }; + +export const getTransactionTags = async () => { + const supabase = createClient(); + const user = await getUser(); + const teamId = user?.data?.team_id; + + if (!teamId) { + return null; + } + + return unstable_cache( + async () => { + return getTransactionTagsQuery(supabase, teamId); + }, + ["transaction_tags", teamId], + { + tags: [`transaction_tags_${teamId}`], + revalidate: 180, + }, + )(); +}; diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index c65387eab4..eb2690596a 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -1303,3 +1303,14 @@ export async function getLastInvoiceNumberQuery( return { data }; } + +export async function getTransactionTagsQuery( + supabase: Client, + teamId: string, +) { + return supabase + .from("transaction_tags") + .select("*") + .eq("team_id", teamId) + .order("created_at", { ascending: false }); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 9647a694db..fb047810a9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -61,10 +61,12 @@ "./scroll-area": "./src/components/scroll-area.tsx", "./select": "./src/components/select.tsx", "./sheet": "./src/components/sheet.tsx", + "./badge": "./src/components/badge.tsx", "./separator": "./src/components/separator.tsx", "./skeleton": "./src/components/skeleton.tsx", "./spinner": "./src/components/spinner.tsx", "./switch": "./src/components/switch.tsx", + "./multiple-selector": "./src/components/multiple-selector.tsx", "./table": "./src/components/table.tsx", "./tabs": "./src/components/tabs.tsx", "./tailwind.config": "./tailwind.config.ts", diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx new file mode 100644 index 0000000000..992c6f2dcf --- /dev/null +++ b/packages/ui/src/components/badge.tsx @@ -0,0 +1,36 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "../utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + tag: "font-mono text-[#878787] bg-[#F2F1EF] text-[10px] dark:bg-[#1D1D1D] border-none font-normal rounded-none", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/ui/src/components/multiple-selector.tsx b/packages/ui/src/components/multiple-selector.tsx new file mode 100644 index 0000000000..58c0a811d8 --- /dev/null +++ b/packages/ui/src/components/multiple-selector.tsx @@ -0,0 +1,635 @@ +"use client"; + +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { X } from "lucide-react"; +import * as React from "react"; +import { forwardRef, useEffect } from "react"; + +import { cn } from "../utils"; +import { Badge } from "./badge"; +import { Command, CommandGroup, CommandItem, CommandList } from "./command"; + +export interface Option { + value: string; + label: string; + create?: boolean; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface MultipleSelectorProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => Option[]; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; + /** hide the clear all button. */ + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter( + (val) => !picked.find((p) => p.value === val.value), + ); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [, value] of Object.entries(groupOption)) { + if ( + value.some((option) => targetOption.find((p) => p.value === option.value)) + ) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = "CommandEmpty"; + +const MultipleSelector = React.forwardRef< + MultipleSelectorRef, + MultipleSelectorProps +>( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = React.useState(""); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]), + }), + [selected], + ); + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (open) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchend", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + }; + }, [open]); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + }; + + const exec = async () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [ + ...selected, + { value: inputValue, label: inputValue, create: true }, + ]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn( + "h-auto overflow-visible bg-transparent", + commandProps?.className, + )} + shouldFilter={ + commandProps?.shouldFilter !== undefined + ? commandProps.shouldFilter + : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={ + hidePlaceholderWhenSelected && selected.length !== 0 + ? "" + : placeholder + } + className={cn( + "flex-1 bg-transparent outline-none placeholder:text-muted-foreground", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> + +
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && + "cursor-default text-muted-foreground", + )} + > + {option.label} + + ); + })} + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = "MultipleSelector"; +export default MultipleSelector; From a6c370b6839d4f871799a0b6dfc0f5e126679ae7 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Mon, 18 Nov 2024 15:36:06 +0100 Subject: [PATCH 2/8] wip --- .../src/actions/create-tag-action.tsx | 27 +++++ .../src/actions/create-tags-action.ts | 23 ---- .../create-tracker-project-tag-action.ts | 32 +++++ .../actions/create-transaction-tag-action.ts | 32 +++++ .../actions/delete-transaction-tag-action.ts | 32 +++++ apps/dashboard/src/actions/schema.ts | 16 ++- .../(app)/(sidebar)/transactions/page.tsx | 2 + .../(sidebar)/transactions/search-params.ts | 1 + .../components/forms/tracker-project-form.tsx | 29 ++++- apps/dashboard/src/components/select-tags.tsx | 58 +++++---- .../tables/tracker/data-table-header.tsx | 13 +++ .../tables/tracker/data-table-row.tsx | 70 +++++------ .../tables/transactions/columns.tsx | 20 ++++ .../tables/transactions/data-table-header.tsx | 16 ++- .../src/components/transaction-details.tsx | 24 +++- packages/events/src/events.ts | 12 ++ .../supabase/src/queries/cached-queries.ts | 10 +- packages/supabase/src/queries/index.ts | 28 +++-- packages/supabase/src/types/db.ts | 110 +++++++++++++++++- .../ui/src/components/multiple-selector.tsx | 10 +- 20 files changed, 465 insertions(+), 100 deletions(-) create mode 100644 apps/dashboard/src/actions/create-tag-action.tsx delete mode 100644 apps/dashboard/src/actions/create-tags-action.ts create mode 100644 apps/dashboard/src/actions/create-tracker-project-tag-action.ts create mode 100644 apps/dashboard/src/actions/create-transaction-tag-action.ts create mode 100644 apps/dashboard/src/actions/delete-transaction-tag-action.ts diff --git a/apps/dashboard/src/actions/create-tag-action.tsx b/apps/dashboard/src/actions/create-tag-action.tsx new file mode 100644 index 0000000000..81cf743e76 --- /dev/null +++ b/apps/dashboard/src/actions/create-tag-action.tsx @@ -0,0 +1,27 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { authActionClient } from "./safe-action"; +import { createTagSchema } from "./schema"; + +export const createTagAction = authActionClient + .schema(createTagSchema) + .metadata({ + name: "create-tag", + track: { + event: LogEvents.CreateTag.name, + channel: LogEvents.CreateTag.channel, + }, + }) + .action(async ({ parsedInput: { name }, ctx: { user, supabase } }) => { + const { data } = await supabase + .from("tags") + .insert({ + name, + team_id: user.team_id!, + }) + .select("id, name") + .single(); + + return data; + }); diff --git a/apps/dashboard/src/actions/create-tags-action.ts b/apps/dashboard/src/actions/create-tags-action.ts deleted file mode 100644 index 6e39db339b..0000000000 --- a/apps/dashboard/src/actions/create-tags-action.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use server"; - -import { LogEvents } from "@midday/events/events"; -import { authActionClient } from "./safe-action"; -import { createTagsSchema } from "./schema"; - -export const createTagsAction = authActionClient - .schema(createTagsSchema) - .metadata({ - name: "create-tags", - track: { - event: LogEvents.CreateTag.name, - channel: LogEvents.CreateTag.channel, - }, - }) - .action(async ({ parsedInput: tags, ctx: { user, supabase } }) => { - const { data, error } = await supabase - .from("transaction_tags") - .insert(tags.map((tag) => ({ name: tag.name, team_id: user.team_id }))); - - console.log(error); - return data; - }); diff --git a/apps/dashboard/src/actions/create-tracker-project-tag-action.ts b/apps/dashboard/src/actions/create-tracker-project-tag-action.ts new file mode 100644 index 0000000000..ff584afe31 --- /dev/null +++ b/apps/dashboard/src/actions/create-tracker-project-tag-action.ts @@ -0,0 +1,32 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { revalidateTag } from "next/cache"; +import { authActionClient } from "./safe-action"; +import { createTrackerProjectTagSchema } from "./schema"; + +export const createTrackerProjectTagAction = authActionClient + .schema(createTrackerProjectTagSchema) + .metadata({ + name: "create-tracker-project-tag", + track: { + event: LogEvents.CreateTrackerProjectTag.name, + channel: LogEvents.CreateTrackerProjectTag.channel, + }, + }) + .action( + async ({ + parsedInput: { tagId, trackerProjectId }, + ctx: { user, supabase }, + }) => { + const { data } = await supabase.from("tracker_project_tags").insert({ + tag_id: tagId, + tracker_project_id: trackerProjectId, + team_id: user.team_id!, + }); + + revalidateTag(`tracker_projects_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/create-transaction-tag-action.ts b/apps/dashboard/src/actions/create-transaction-tag-action.ts new file mode 100644 index 0000000000..b414102a5c --- /dev/null +++ b/apps/dashboard/src/actions/create-transaction-tag-action.ts @@ -0,0 +1,32 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { revalidateTag } from "next/cache"; +import { authActionClient } from "./safe-action"; +import { createTransactionTagSchema } from "./schema"; + +export const createTransactionTagAction = authActionClient + .schema(createTransactionTagSchema) + .metadata({ + name: "create-transaction-tag", + track: { + event: LogEvents.CreateTransactionTag.name, + channel: LogEvents.CreateTransactionTag.channel, + }, + }) + .action( + async ({ + parsedInput: { tagId, transactionId }, + ctx: { user, supabase }, + }) => { + const { data } = await supabase.from("transaction_tags").insert({ + tag_id: tagId, + transaction_id: transactionId, + team_id: user.team_id!, + }); + + revalidateTag(`transactions_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/delete-transaction-tag-action.ts b/apps/dashboard/src/actions/delete-transaction-tag-action.ts new file mode 100644 index 0000000000..095e8c7662 --- /dev/null +++ b/apps/dashboard/src/actions/delete-transaction-tag-action.ts @@ -0,0 +1,32 @@ +"use server"; + +import { LogEvents } from "@midday/events/events"; +import { revalidateTag } from "next/cache"; +import { authActionClient } from "./safe-action"; +import { deleteTransactionTagSchema } from "./schema"; + +export const deleteTransactionTagAction = authActionClient + .schema(deleteTransactionTagSchema) + .metadata({ + name: "delete-transaction-tag", + track: { + event: LogEvents.DeleteTransactionTag.name, + channel: LogEvents.DeleteTransactionTag.channel, + }, + }) + .action( + async ({ + parsedInput: { tagId, transactionId }, + ctx: { user, supabase }, + }) => { + const { data } = await supabase + .from("transaction_tags") + .delete() + .eq("transaction_id", transactionId) + .eq("tag_id", tagId); + + revalidateTag(`transactions_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index af70ae266b..d63c46f3e4 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -13,7 +13,21 @@ export const updateUserSchema = z.object({ revalidatePath: z.string().optional(), }); -export const createTagsSchema = z.array(z.object({ name: z.string() })); +export const createTagSchema = z.object({ name: z.string() }); +export const createTransactionTagSchema = z.object({ + tagId: z.string(), + transactionId: z.string(), +}); + +export const deleteTransactionTagSchema = z.object({ + tagId: z.string(), + transactionId: z.string(), +}); + +export const createTrackerProjectTagSchema = z.object({ + tagId: z.string(), + trackerProjectId: z.string(), +}); export type UpdateUserFormValues = z.infer; diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx index 8cf414aadc..51262b3d88 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx @@ -39,6 +39,7 @@ export default async function Transactions({ statuses, recurring, accounts, + tags, } = searchParamsCache.parse(searchParams); // Move this in a suspense @@ -59,6 +60,7 @@ export default async function Transactions({ statuses, recurring, accounts, + tags, }; const sort = searchParams?.sort?.split(":"); diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/search-params.ts b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/search-params.ts index 6dfd036e22..06084cbb5a 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/search-params.ts +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/search-params.ts @@ -13,6 +13,7 @@ export const searchParamsCache = createSearchParamsCache({ start: parseAsString, end: parseAsString, categories: parseAsArrayOf(parseAsString), + tags: parseAsArrayOf(parseAsString), accounts: parseAsArrayOf(parseAsString), assignees: parseAsArrayOf(parseAsString), recurring: parseAsArrayOf( diff --git a/apps/dashboard/src/components/forms/tracker-project-form.tsx b/apps/dashboard/src/components/forms/tracker-project-form.tsx index e52826fde4..e35715998d 100644 --- a/apps/dashboard/src/components/forms/tracker-project-form.tsx +++ b/apps/dashboard/src/components/forms/tracker-project-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { createTrackerProjectTagAction } from "@/actions/create-tracker-project-tag-action"; import type { Customer } from "@/components/invoice/customer-details"; import { uniqueCurrencies } from "@midday/location/currencies"; import { Button } from "@midday/ui/button"; @@ -26,6 +27,7 @@ import { import { Switch } from "@midday/ui/switch"; import { Textarea } from "@midday/ui/textarea"; import { Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; import { useEffect, useState } from "react"; import { SearchCustomer } from "../search-customer"; import { SelectTags } from "../select-tags"; @@ -44,6 +46,7 @@ export function TrackerProjectForm({ customers, }: Props) { const [isOpen, setIsOpen] = useState(false); + const createTrackerProjectTag = useAction(createTrackerProjectTagAction); useEffect(() => { setIsOpen(Boolean(form.getValues("billable"))); @@ -101,7 +104,31 @@ export function TrackerProjectForm({ Expense Tags - + + // createTrackerProjectTag.execute({ + // tagId, + // trackerProjectId: form.getValues("id"), + // }) + // } + // tags={data?.tags?.map((tag) => ({ + // label: tag.tag.name, + // value: tag.tag.name, + // id: tag.tag.id, + // }))} + onSelect={(tag) => { + createTrackerProjectTag.execute({ + tagId: tag.id, + trackerProjectId: form.getValues("id"), + }); + }} + onRemove={(tag) => { + createTrackerProjectTag.execute({ + tagId: tag.id, + trackerProjectId: form.getValues("id"), + }); + }} + /> Tags help categorize and track project expenses. diff --git a/apps/dashboard/src/components/select-tags.tsx b/apps/dashboard/src/components/select-tags.tsx index baf94b7bdd..b02c8c2e6c 100644 --- a/apps/dashboard/src/components/select-tags.tsx +++ b/apps/dashboard/src/components/select-tags.tsx @@ -1,27 +1,33 @@ -import { createTagsAction } from "@/actions/create-tags-action"; +import { createTagAction } from "@/actions/create-tag-action"; import { useUserContext } from "@/store/user/hook"; import { createClient } from "@midday/supabase/client"; -import { getTransactionTagsQuery } from "@midday/supabase/queries"; +import { getTagsQuery } from "@midday/supabase/queries"; import MultipleSelector, { type Option } from "@midday/ui/multiple-selector"; import { useAction } from "next-safe-action/hooks"; import React, { useEffect, useState } from "react"; type Props = { tags?: Option[]; + onSelect?: (tag: Option) => void; + onRemove?: (tag: Option & { id: string }) => void; }; -export function SelectTags({ tags }: Props) { - const [isLoading, setIsLoading] = useState(false); - const [data, setData] = useState(tags ?? []); - const createTags = useAction(createTagsAction); +export function SelectTags({ tags, onSelect, onRemove }: Props) { const supabase = createClient(); + + const [data, setData] = useState(tags ?? []); + const [selected, setSelected] = useState(tags ?? []); + const createTag = useAction(createTagAction, { + onSuccess: ({ data }) => { + onSelect(data); + }, + }); + const { team_id: teamId } = useUserContext((state) => state.data); useEffect(() => { async function fetchData() { - setIsLoading(true); - - const { data } = await getTransactionTagsQuery(supabase, teamId); + const { data } = await getTagsQuery(supabase, teamId); if (data?.length) { setData( @@ -32,30 +38,42 @@ export function SelectTags({ tags }: Props) { })), ); } - - setIsLoading(false); } - if (!data?.length) { - fetchData(); - } + fetchData(); }, [teamId]); return (
no results found.

} + emptyIndicator={

No results found.

} + onCreate={(option) => { + createTag.execute({ name: option.value }); + }} onChange={(options) => { - const newTags = options.filter((option) => option.create); + setSelected(options); + + const newTag = options.find( + (tag) => !selected.find((opt) => opt.value === tag.value), + ); + + if (newTag) { + onSelect?.(newTag); + return; + } - console.log(newTags); + if (options.length < selected.length) { + const removedTag = selected.find( + (tag) => !options.find((opt) => opt.value === tag.value), + ); - if (newTags.length > 0) { - createTags.execute(newTags.map((tag) => ({ name: tag.value }))); + if (removedTag) { + onRemove?.(removedTag); + } } }} /> diff --git a/apps/dashboard/src/components/tables/tracker/data-table-header.tsx b/apps/dashboard/src/components/tables/tracker/data-table-header.tsx index b869616e19..6630e51665 100644 --- a/apps/dashboard/src/components/tables/tracker/data-table-header.tsx +++ b/apps/dashboard/src/components/tables/tracker/data-table-header.tsx @@ -97,6 +97,19 @@ export function DataTableHeader() { )} + + + + + + + )} + {isVisible("bank_account") && (
diff --git a/packages/events/src/events.ts b/packages/events/src/events.ts index 6a6bdf9f75..c5740f2d67 100644 --- a/packages/events/src/events.ts +++ b/packages/events/src/events.ts @@ -219,4 +219,16 @@ export const LogEvents = { name: "Create Tag", channel: "tag", }, + CreateTransactionTag: { + name: "Create Transaction Tag", + channel: "tag", + }, + DeleteTransactionTag: { + name: "Delete Transaction Tag", + channel: "tag", + }, + CreateTrackerProjectTag: { + name: "Create Tracker Project Tag", + channel: "tag", + }, }; diff --git a/packages/supabase/src/queries/cached-queries.ts b/packages/supabase/src/queries/cached-queries.ts index efd5221863..136199948b 100644 --- a/packages/supabase/src/queries/cached-queries.ts +++ b/packages/supabase/src/queries/cached-queries.ts @@ -30,6 +30,7 @@ import { getPaymentStatusQuery, getRunwayQuery, getSpendingQuery, + getTagsQuery, getTeamBankAccountsQuery, getTeamInvitesQuery, getTeamMembersQuery, @@ -38,7 +39,6 @@ import { getTeamsByUserIdQuery, getTrackerProjectsQuery, getTrackerRecordsByRangeQuery, - getTransactionTagsQuery, getTransactionsQuery, getUserInvitesQuery, getUserQuery, @@ -581,7 +581,7 @@ export const getLastInvoiceNumber = async () => { )(); }; -export const getTransactionTags = async () => { +export const getTags = async () => { const supabase = createClient(); const user = await getUser(); const teamId = user?.data?.team_id; @@ -592,11 +592,11 @@ export const getTransactionTags = async () => { return unstable_cache( async () => { - return getTransactionTagsQuery(supabase, teamId); + return getTagsQuery(supabase, teamId); }, - ["transaction_tags", teamId], + ["tags", teamId], { - tags: [`transaction_tags_${teamId}`], + tags: [`tags_${teamId}`], revalidate: 180, }, )(); diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index eb2690596a..98978dfb29 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -164,6 +164,7 @@ export type GetTransactionsParams = { statuses?: string[]; attachments?: "include" | "exclude"; categories?: string[]; + tags?: string[]; accounts?: string[]; assignees?: string[]; type?: "income" | "expense"; @@ -183,6 +184,7 @@ export async function getTransactionsQuery( statuses, attachments, categories, + tags, type, accounts, start, @@ -208,6 +210,7 @@ export async function getTransactionsQuery( "category:transaction_categories(id, name, color, slug)", "bank_account:bank_accounts(id, name, currency, bank_connection:bank_connections(id, logo_url))", "attachments:transaction_attachments(id, name, size, path, type)", + "tags:transaction_tags(id, tag_id, tag:tags(id, name))", "vat:calculated_vat", ]; @@ -228,6 +231,8 @@ export async function getTransactionsQuery( query.order("bank_account(name)", { ascending }); } else if (column === "category") { query.order("category(name)", { ascending }); + } else if (column === "tags") { + query.order("is_transaction_tagged", { ascending }); } else { query.order(column, { ascending }); } @@ -280,6 +285,15 @@ export async function getTransactionsQuery( query.or(matchCategory); } + if (tags) { + query + .select( + [...columns, "temp_filter_tags:transaction_tags!inner()"].join(","), + ) + .eq("team_id", teamId) + .in("temp_filter_tags.tag_id", tags); + } + if (recurring) { if (recurring.includes("all")) { query.eq("recurring", true); @@ -338,6 +352,7 @@ export async function getTransactionQuery(supabase: Client, id: string) { "assigned:assigned_id(*)", "category:category_slug(id, name, vat)", "attachments:transaction_attachments(*)", + "tags:transaction_tags(id, tag:tags(id, name))", "bank_account:bank_accounts(id, name, currency, bank_connection:bank_connections(id, logo_url))", "vat:calculated_vat", ]; @@ -870,7 +885,7 @@ export async function getTrackerProjectQuery( ) { return supabase .from("tracker_projects") - .select("*") + .select("*, tags:tracker_project_tags(id, tag:tags(id, name))") .eq("id", params.projectId) .eq("team_id", params.teamId) .single(); @@ -912,7 +927,7 @@ export async function getTrackerProjectsQuery( const query = supabase .from("tracker_projects") .select( - "*, total_duration, users:get_assigned_users_for_project, total_amount:get_project_total_amount, customer:customer_id(id, name, website)", + "*, total_duration, users:get_assigned_users_for_project, total_amount:get_project_total_amount, customer:customer_id(id, name, website), tags:tracker_project_tags(id, tag:tags(id, name))", { count: "exact", }, @@ -946,6 +961,8 @@ export async function getTrackerProjectsQuery( // query.order("assigned_id", { ascending: value === "asc" }); } else if (column === "customer") { query.order("customer(name)", { ascending: value === "asc" }); + } else if (column === "tags") { + query.order("is_project_tagged", { ascending: value === "asc" }); } else { query.order(column, { ascending: value === "asc" }); } @@ -1304,12 +1321,9 @@ export async function getLastInvoiceNumberQuery( return { data }; } -export async function getTransactionTagsQuery( - supabase: Client, - teamId: string, -) { +export async function getTagsQuery(supabase: Client, teamId: string) { return supabase - .from("transaction_tags") + .from("tags") .select("*") .eq("team_id", teamId) .order("created_at", { ascending: false }); diff --git a/packages/supabase/src/types/db.ts b/packages/supabase/src/types/db.ts index d10ecdcab4..9e4a414f84 100644 --- a/packages/supabase/src/types/db.ts +++ b/packages/supabase/src/types/db.ts @@ -781,6 +781,35 @@ export type Database = { }, ] } + tags: { + Row: { + created_at: string + id: string + name: string + team_id: string + } + Insert: { + created_at?: string + id?: string + name: string + team_id: string + } + Update: { + created_at?: string + id?: string + name?: string + team_id?: string + } + Relationships: [ + { + foreignKeyName: "tags_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "teams" + referencedColumns: ["id"] + }, + ] + } teams: { Row: { base_currency: string | null @@ -894,6 +923,52 @@ export type Database = { }, ] } + tracker_project_tags: { + Row: { + created_at: string + id: string + tag_id: string + team_id: string + tracker_project_id: string + } + Insert: { + created_at?: string + id?: string + tag_id: string + team_id: string + tracker_project_id: string + } + Update: { + created_at?: string + id?: string + tag_id?: string + team_id?: string + tracker_project_id?: string + } + Relationships: [ + { + foreignKeyName: "project_tags_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "tags" + referencedColumns: ["id"] + }, + { + foreignKeyName: "project_tags_tracker_project_id_fkey" + columns: ["tracker_project_id"] + isOneToOne: false + referencedRelation: "tracker_projects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tracker_project_tags_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "teams" + referencedColumns: ["id"] + }, + ] + } tracker_projects: { Row: { billable: boolean | null @@ -1148,22 +1223,32 @@ export type Database = { Row: { created_at: string id: string - name: string + tag_id: string team_id: string + transaction_id: string } Insert: { created_at?: string id?: string - name: string + tag_id: string team_id: string + transaction_id: string } Update: { created_at?: string id?: string - name?: string + tag_id?: string team_id?: string + transaction_id?: string } Relationships: [ + { + foreignKeyName: "transaction_tags_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "tags" + referencedColumns: ["id"] + }, { foreignKeyName: "transaction_tags_team_id_fkey" columns: ["team_id"] @@ -1171,6 +1256,13 @@ export type Database = { referencedRelation: "teams" referencedColumns: ["id"] }, + { + foreignKeyName: "transaction_tags_transaction_id_fkey" + columns: ["transaction_id"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, ] } transactions: { @@ -2069,6 +2161,18 @@ export type Database = { } Returns: boolean } + is_project_tagged: { + Args: { + project: unknown + } + Returns: boolean + } + is_transaction_tagged: { + Args: { + transaction: unknown + } + Returns: boolean + } match_transaction_with_inbox: { Args: { p_transaction_id: string diff --git a/packages/ui/src/components/multiple-selector.tsx b/packages/ui/src/components/multiple-selector.tsx index 58c0a811d8..b82e86a413 100644 --- a/packages/ui/src/components/multiple-selector.tsx +++ b/packages/ui/src/components/multiple-selector.tsx @@ -49,6 +49,7 @@ interface MultipleSelectorProps { **/ onSearchSync?: (value: string) => Option[]; onChange?: (options: Option[]) => void; + onCreate?: (option: Option) => void; /** Limit the maximum number of selected options. */ maxSelected?: number; /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ @@ -179,6 +180,7 @@ const MultipleSelector = React.forwardRef< { value, onChange, + onCreate, placeholder, defaultOptions: arrayDefaultOptions = [], options: arrayOptions, @@ -375,12 +377,12 @@ const MultipleSelector = React.forwardRef< return; } setInputValue(""); - const newOptions = [ - ...selected, - { value: inputValue, label: inputValue, create: true }, - ]; + const newOption = { value: inputValue, label: inputValue }; + const newOptions = [...selected, newOption]; + setSelected(newOptions); onChange?.(newOptions); + onCreate?.(newOption); }} > {`Create "${inputValue}"`} From 0b2a0ed9ddae43c08e0e5b435ca607ebc1c5c94c Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Mon, 18 Nov 2024 19:01:17 +0100 Subject: [PATCH 3/8] wip --- .../create-tracker-project-tag-action.ts | 32 ------------ .../actions/project/create-project-action.ts | 44 +++++++++++------ .../project/create-project-tag-action.ts | 29 +++++++++++ .../project/delete-project-tag-action.ts | 29 +++++++++++ .../actions/project/update-project-action.ts | 3 +- apps/dashboard/src/actions/schema.ts | 29 ++++++++++- .../components/forms/tracker-project-form.tsx | 49 +++++++++++-------- apps/dashboard/src/components/select-tags.tsx | 8 ++- .../sheets/tracker-update-sheet.tsx | 7 +++ .../tables/tracker/data-table-header.tsx | 2 +- packages/events/src/events.ts | 8 ++- packages/supabase/src/mutations/index.ts | 12 +---- 12 files changed, 166 insertions(+), 86 deletions(-) delete mode 100644 apps/dashboard/src/actions/create-tracker-project-tag-action.ts create mode 100644 apps/dashboard/src/actions/project/create-project-tag-action.ts create mode 100644 apps/dashboard/src/actions/project/delete-project-tag-action.ts diff --git a/apps/dashboard/src/actions/create-tracker-project-tag-action.ts b/apps/dashboard/src/actions/create-tracker-project-tag-action.ts deleted file mode 100644 index ff584afe31..0000000000 --- a/apps/dashboard/src/actions/create-tracker-project-tag-action.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use server"; - -import { LogEvents } from "@midday/events/events"; -import { revalidateTag } from "next/cache"; -import { authActionClient } from "./safe-action"; -import { createTrackerProjectTagSchema } from "./schema"; - -export const createTrackerProjectTagAction = authActionClient - .schema(createTrackerProjectTagSchema) - .metadata({ - name: "create-tracker-project-tag", - track: { - event: LogEvents.CreateTrackerProjectTag.name, - channel: LogEvents.CreateTrackerProjectTag.channel, - }, - }) - .action( - async ({ - parsedInput: { tagId, trackerProjectId }, - ctx: { user, supabase }, - }) => { - const { data } = await supabase.from("tracker_project_tags").insert({ - tag_id: tagId, - tracker_project_id: trackerProjectId, - team_id: user.team_id!, - }); - - revalidateTag(`tracker_projects_${user.team_id}`); - - return data; - }, - ); diff --git a/apps/dashboard/src/actions/project/create-project-action.ts b/apps/dashboard/src/actions/project/create-project-action.ts index 599d5c38f9..ab3b740c7c 100644 --- a/apps/dashboard/src/actions/project/create-project-action.ts +++ b/apps/dashboard/src/actions/project/create-project-action.ts @@ -18,23 +18,35 @@ export const createProjectAction = authActionClient channel: LogEvents.ProjectCreated.channel, }, }) - .action(async ({ parsedInput: params, ctx: { user, supabase } }) => { - const { data } = await createProject(supabase, { - ...params, - team_id: user.team_id, - }); + .action( + async ({ parsedInput: { tags, ...params }, ctx: { user, supabase } }) => { + const { data } = await createProject(supabase, { + ...params, + team_id: user.team_id!, + }); - if (!data) { - throw new Error("Failed to create project"); - } + if (!data) { + throw new Error("Failed to create project"); + } - cookies().set({ - name: Cookies.LastProject, - value: data.id, - expires: addYears(new Date(), 1), - }); + if (tags?.length) { + await supabase.from("tracker_project_tags").insert( + tags.map((tag) => ({ + tag_id: tag.id, + tracker_project_id: data?.id ?? "", + team_id: user.team_id ?? "", + })), + ); + } - revalidateTag(`tracker_projects_${user.team_id}`); + cookies().set({ + name: Cookies.LastProject, + value: data.id, + expires: addYears(new Date(), 1), + }); - return data; - }); + revalidateTag(`tracker_projects_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/project/create-project-tag-action.ts b/apps/dashboard/src/actions/project/create-project-tag-action.ts new file mode 100644 index 0000000000..b91a101590 --- /dev/null +++ b/apps/dashboard/src/actions/project/create-project-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 { createProjectTagSchema } from "../schema"; + +export const createProjectTagAction = authActionClient + .schema(createProjectTagSchema) + .metadata({ + name: "create-project-tag", + track: { + event: LogEvents.CreateProjectTag.name, + channel: LogEvents.CreateProjectTag.channel, + }, + }) + .action( + async ({ parsedInput: { tagId, projectId }, ctx: { user, supabase } }) => { + const { data } = await supabase.from("tracker_project_tags").insert({ + tag_id: tagId, + tracker_project_id: projectId, + team_id: user.team_id!, + }); + + revalidateTag(`tracker_projects_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/project/delete-project-tag-action.ts b/apps/dashboard/src/actions/project/delete-project-tag-action.ts new file mode 100644 index 0000000000..e0bc335fb6 --- /dev/null +++ b/apps/dashboard/src/actions/project/delete-project-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 { deleteProjectTagSchema } from "../schema"; + +export const deleteProjectTagAction = authActionClient + .schema(deleteProjectTagSchema) + .metadata({ + name: "delete-project-tag", + track: { + event: LogEvents.DeleteProjectTag.name, + channel: LogEvents.DeleteProjectTag.channel, + }, + }) + .action( + async ({ parsedInput: { tagId, projectId }, ctx: { user, supabase } }) => { + const { data } = await supabase + .from("tracker_project_tags") + .delete() + .eq("tracker_project_id", projectId) + .eq("tag_id", tagId); + + revalidateTag(`tracker_projects_${user.team_id}`); + + return data; + }, + ); diff --git a/apps/dashboard/src/actions/project/update-project-action.ts b/apps/dashboard/src/actions/project/update-project-action.ts index 6b09fdd10b..614138b0ab 100644 --- a/apps/dashboard/src/actions/project/update-project-action.ts +++ b/apps/dashboard/src/actions/project/update-project-action.ts @@ -15,7 +15,8 @@ export const updateProjectAction = authActionClient }, }) .action(async ({ parsedInput: params, ctx: { user, supabase } }) => { - const { id, ...data } = params; + // We store tags in the form state, it's deleted from the action + const { id, tags, ...data } = params; await supabase.from("tracker_projects").update(data).eq("id", id); diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index d63c46f3e4..48bc611cc6 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -24,9 +24,14 @@ export const deleteTransactionTagSchema = z.object({ transactionId: z.string(), }); -export const createTrackerProjectTagSchema = z.object({ +export const deleteProjectTagSchema = z.object({ tagId: z.string(), - trackerProjectId: z.string(), + projectId: z.string(), +}); + +export const createProjectTagSchema = z.object({ + tagId: z.string(), + projectId: z.string(), }); export type UpdateUserFormValues = z.infer; @@ -336,6 +341,16 @@ export const createProjectSchema = z.object({ currency: z.string().optional(), status: z.enum(["in_progress", "completed"]).optional(), customer_id: z.string().uuid().nullable().optional(), + tags: z + .array( + z.object({ + id: z.string().uuid(), + label: z.string(), + value: z.string(), + }), + ) + .optional() + .nullable(), }); export const updateProjectSchema = z.object({ @@ -348,6 +363,16 @@ export const updateProjectSchema = z.object({ currency: z.string().optional(), status: z.enum(["in_progress", "completed"]).optional(), customer_id: z.string().uuid().nullable().optional(), + tags: z + .array( + z.object({ + id: z.string().uuid(), + label: z.string(), + value: z.string(), + }), + ) + .optional() + .nullable(), }); export const deleteProjectSchema = z.object({ diff --git a/apps/dashboard/src/components/forms/tracker-project-form.tsx b/apps/dashboard/src/components/forms/tracker-project-form.tsx index e35715998d..23b48f624c 100644 --- a/apps/dashboard/src/components/forms/tracker-project-form.tsx +++ b/apps/dashboard/src/components/forms/tracker-project-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { createTrackerProjectTagAction } from "@/actions/create-tracker-project-tag-action"; +import { createProjectTagAction } from "@/actions/project/create-project-tag-action"; +import { deleteProjectTagAction } from "@/actions/project/delete-project-tag-action"; import type { Customer } from "@/components/invoice/customer-details"; import { uniqueCurrencies } from "@midday/location/currencies"; import { Button } from "@midday/ui/button"; @@ -46,7 +47,11 @@ export function TrackerProjectForm({ customers, }: Props) { const [isOpen, setIsOpen] = useState(false); - const createTrackerProjectTag = useAction(createTrackerProjectTagAction); + + const deleteProjectTag = useAction(deleteProjectTagAction); + const createProjectTag = useAction(createProjectTagAction); + + const isEdit = form.getValues("id") !== undefined; useEffect(() => { setIsOpen(Boolean(form.getValues("billable"))); @@ -105,29 +110,31 @@ export function TrackerProjectForm({ - // createTrackerProjectTag.execute({ - // tagId, - // trackerProjectId: form.getValues("id"), - // }) - // } - // tags={data?.tags?.map((tag) => ({ - // label: tag.tag.name, - // value: tag.tag.name, - // id: tag.tag.id, - // }))} - onSelect={(tag) => { - createTrackerProjectTag.execute({ - tagId: tag.id, - trackerProjectId: form.getValues("id"), - }); - }} + tags={form.getValues("tags")} onRemove={(tag) => { - createTrackerProjectTag.execute({ + deleteProjectTag.execute({ tagId: tag.id, - trackerProjectId: form.getValues("id"), + projectId: form.getValues("id"), }); }} + // Only for create projects + onChange={(tags) => { + if (!isEdit) { + form.setValue("tags", tags, { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + // Only for edit projects + onSelect={(tag) => { + if (isEdit) { + createProjectTag.execute({ + tagId: tag.id, + projectId: form.getValues("id"), + }); + } + }} /> diff --git a/apps/dashboard/src/components/select-tags.tsx b/apps/dashboard/src/components/select-tags.tsx index b02c8c2e6c..c60aa36917 100644 --- a/apps/dashboard/src/components/select-tags.tsx +++ b/apps/dashboard/src/components/select-tags.tsx @@ -10,9 +10,10 @@ type Props = { tags?: Option[]; onSelect?: (tag: Option) => void; onRemove?: (tag: Option & { id: string }) => void; + onChange?: (tags: Option[]) => void; }; -export function SelectTags({ tags, onSelect, onRemove }: Props) { +export function SelectTags({ tags, onSelect, onRemove, onChange }: Props) { const supabase = createClient(); const [data, setData] = useState(tags ?? []); @@ -43,6 +44,10 @@ export function SelectTags({ tags, onSelect, onRemove }: Props) { fetchData(); }, [teamId]); + useEffect(() => { + setSelected(tags ?? []); + }, [tags]); + return (
{ setSelected(options); + onChange?.(options); const newTag = options.find( (tag) => !selected.find((opt) => opt.value === tag.value), diff --git a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx index 13eef13ec0..7f746c8190 100644 --- a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx @@ -64,6 +64,7 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { estimate: 0, currency: undefined, customer_id: undefined, + tags: undefined, }, }); @@ -85,6 +86,12 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { estimate: data.estimate ?? undefined, currency: data.currency ?? undefined, customer_id: data.customer_id ?? undefined, + tags: + data.tags?.map((tag) => ({ + id: tag.tag?.id ?? "", + label: tag.tag?.name ?? "", + value: tag.tag?.name ?? "", + })) ?? undefined, }); } }; diff --git a/apps/dashboard/src/components/tables/tracker/data-table-header.tsx b/apps/dashboard/src/components/tables/tracker/data-table-header.tsx index 6630e51665..cbaa03c774 100644 --- a/apps/dashboard/src/components/tables/tracker/data-table-header.tsx +++ b/apps/dashboard/src/components/tables/tracker/data-table-header.tsx @@ -98,7 +98,7 @@ export function DataTableHeader() { - + - +
diff --git a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx index adf66dd762..000548d6f5 100644 --- a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx @@ -33,7 +33,7 @@ import { ScrollArea } from "@midday/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader } from "@midday/ui/sheet"; import { useToast } from "@midday/ui/use-toast"; import { useAction } from "next-safe-action/hooks"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import type { z } from "zod"; @@ -46,6 +46,7 @@ type Props = { export function TrackerUpdateSheet({ teamId, customers }: Props) { const { toast } = useToast(); const isDesktop = useMediaQuery("(min-width: 768px)"); + const [isLoading, setIsLoading] = useState(false); const { setParams, update, projectId } = useTrackerParams(); const supabase = createClient(); const id = projectId ?? ""; @@ -70,6 +71,8 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { useEffect(() => { const fetchData = async () => { + setIsLoading(true); + const { data } = await getTrackerProjectQuery(supabase, { teamId, projectId: id, @@ -94,6 +97,8 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { })) ?? undefined, }); } + + setIsLoading(false); }; if (id) { @@ -135,6 +140,10 @@ export function TrackerUpdateSheet({ teamId, customers }: Props) { } }, [isOpen]); + if (isLoading) { + return null; + } + if (isDesktop) { return ( From e020e47bdfd30086c904a9d0c1e97494e6aaab3b Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Tue, 19 Nov 2024 11:51:54 +0100 Subject: [PATCH 8/8] wip --- .../actions/project/create-project-action.ts | 4 ++-- apps/dashboard/src/actions/schema.ts | 2 -- .../components/forms/tracker-project-form.tsx | 19 +++++++++++++------ apps/dashboard/src/components/select-tags.tsx | 13 +++++++++++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/actions/project/create-project-action.ts b/apps/dashboard/src/actions/project/create-project-action.ts index ab3b740c7c..688a7c3aab 100644 --- a/apps/dashboard/src/actions/project/create-project-action.ts +++ b/apps/dashboard/src/actions/project/create-project-action.ts @@ -33,8 +33,8 @@ export const createProjectAction = authActionClient await supabase.from("tracker_project_tags").insert( tags.map((tag) => ({ tag_id: tag.id, - tracker_project_id: data?.id ?? "", - team_id: user.team_id ?? "", + tracker_project_id: data?.id, + team_id: user.team_id!, })), ); } diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index 48bc611cc6..be805874ec 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -345,7 +345,6 @@ export const createProjectSchema = z.object({ .array( z.object({ id: z.string().uuid(), - label: z.string(), value: z.string(), }), ) @@ -367,7 +366,6 @@ export const updateProjectSchema = z.object({ .array( z.object({ id: z.string().uuid(), - label: z.string(), value: z.string(), }), ) diff --git a/apps/dashboard/src/components/forms/tracker-project-form.tsx b/apps/dashboard/src/components/forms/tracker-project-form.tsx index 9ee1b31fd5..a6c98ad135 100644 --- a/apps/dashboard/src/components/forms/tracker-project-form.tsx +++ b/apps/dashboard/src/components/forms/tracker-project-form.tsx @@ -118,12 +118,19 @@ export function TrackerProjectForm({ }); }} // Only for create projects - onChange={(tags) => { + onCreate={(tag) => { if (!isEdit) { - form.setValue("tags", tags, { - shouldDirty: true, - shouldValidate: true, - }); + form.setValue( + "tags", + [ + ...(form.getValues("tags") ?? []), + { id: tag.id, value: tag.name }, + ], + { + shouldDirty: true, + shouldValidate: true, + }, + ); } }} // Only for edit projects @@ -295,7 +302,7 @@ export function TrackerProjectForm({
diff --git a/apps/dashboard/src/components/select-tags.tsx b/apps/dashboard/src/components/select-tags.tsx index 4716b74886..f9240fd123 100644 --- a/apps/dashboard/src/components/select-tags.tsx +++ b/apps/dashboard/src/components/select-tags.tsx @@ -11,16 +11,25 @@ type Props = { onSelect?: (tag: Option) => void; onRemove?: (tag: Option & { id: string }) => void; onChange?: (tags: Option[]) => void; + onCreate?: (tag: Option) => void; }; -export function SelectTags({ tags, onSelect, onRemove, onChange }: Props) { +export function SelectTags({ + tags, + onSelect, + onRemove, + onChange, + onCreate, +}: Props) { const supabase = createClient(); const [data, setData] = useState(tags ?? []); const [selected, setSelected] = useState(tags ?? []); + const createTag = useAction(createTagAction, { onSuccess: ({ data }) => { - onSelect(data); + onSelect?.(data); + onCreate?.(data); }, });