diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 7a5170ccbb..2ca0de81bf 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ai-sdk/openai": "^0.0.40", + "@ai-sdk/openai": "^0.0.43", "@date-fns/utc": "^1.2.0", "@hookform/resolvers": "^3.9.0", "@midday-ai/engine": "^0.1.0-alpha.22", @@ -29,7 +29,7 @@ "@novu/headless": "^0.24.2", "@sentry/nextjs": "^8", "@supabase/sentry-js-integration": "^0.2.0", - "@tanstack/react-table": "^8.19.3", + "@tanstack/react-table": "^8.20.1", "@team-plain/typescript-sdk": "4.7.0", "@todesktop/client-active-win": "^0.15.0", "@todesktop/client-core": "^1.8.0", @@ -38,7 +38,7 @@ "@uidotdev/usehooks": "^2.4.1", "@upstash/ratelimit": "^2.0.1", "@zip.js/zip.js": "2.7.48", - "ai": "^3.3.0", + "ai": "^3.3.3", "change-case": "^5.4.4", "dub": "^0.35.0", "framer-motion": "^11.3.21", @@ -51,7 +51,7 @@ "next-international": "1.2.4", "next-safe-action": "^7.4.3", "next-themes": "^0.3.0", - "nuqs": "^1.17.6", + "nuqs": "^1.17.8", "react": "18.3.1", "react-colorful": "^5.6.1", "react-dom": "18.3.1", diff --git a/apps/dashboard/src/actions/ai/chat/tools/get-transactions.tsx b/apps/dashboard/src/actions/ai/chat/tools/get-transactions.tsx index 8e0aedb8c3..9fc9dacad6 100644 --- a/apps/dashboard/src/actions/ai/chat/tools/get-transactions.tsx +++ b/apps/dashboard/src/actions/ai/chat/tools/get-transactions.tsx @@ -41,10 +41,8 @@ export function getTransactionsTool({ aiState }: Args) { const searchQuery = name || amount; const filter = { - date: { - from: fromDate, - to: toDate, - }, + start: fromDate, + end: toDate, categories, attachments, }; diff --git a/apps/dashboard/src/actions/ai/chat/tools/ui/show-all-button.tsx b/apps/dashboard/src/actions/ai/chat/tools/ui/show-all-button.tsx index f3ea9135f9..c95beba979 100644 --- a/apps/dashboard/src/actions/ai/chat/tools/ui/show-all-button.tsx +++ b/apps/dashboard/src/actions/ai/chat/tools/ui/show-all-button.tsx @@ -5,16 +5,29 @@ import { isDesktopApp } from "@todesktop/client-core/platform/todesktop"; import { useRouter } from "next/navigation"; type Props = { - path: string; + filter: Record; + q: string; }; -export function ShowAllButton({ path }: Props) { +export function ShowAllButton({ filter, q }: Props) { const { setOpen } = useAssistantStore(); const router = useRouter(); + const params = new URLSearchParams(); + + if (q) { + params.append("q", q); + } + + if (Object.keys(filter).length > 0) { + for (const [key, value] of Object.entries(filter)) { + params.append(key, value); + } + } + const handleOnClick = () => { setOpen(); - router.push(path); + router.push(`/transactions?${params.toString()}`); }; if (isDesktopApp()) { diff --git a/apps/dashboard/src/actions/ai/chat/tools/ui/transactions-ui.tsx b/apps/dashboard/src/actions/ai/chat/tools/ui/transactions-ui.tsx index fd66a11aff..b607356d43 100644 --- a/apps/dashboard/src/actions/ai/chat/tools/ui/transactions-ui.tsx +++ b/apps/dashboard/src/actions/ai/chat/tools/ui/transactions-ui.tsx @@ -18,7 +18,7 @@ type Props = { meta: any; data: any; q: string; - filter: string[]; + filter: Record; }; export function TransactionsUI({ meta, data, q, filter }: Props) { @@ -95,11 +95,7 @@ export function TransactionsUI({ meta, data, q, filter }: Props) { )} - {meta.count > 5 && ( - - )} + {meta.count > 5 && } ); } diff --git a/apps/dashboard/src/actions/ai/filters/generate-filters.ts b/apps/dashboard/src/actions/ai/filters/generate-filters.ts new file mode 100644 index 0000000000..62ebd39892 --- /dev/null +++ b/apps/dashboard/src/actions/ai/filters/generate-filters.ts @@ -0,0 +1,41 @@ +"use server"; + +import { filterQuerySchema } from "@/actions/schema"; +import { openai } from "@ai-sdk/openai"; +import { streamObject } from "ai"; +import { createStreamableValue } from "ai/rsc"; + +export async function generateFilters( + prompt: string, + validFilters: string[], + context: string, +) { + const stream = createStreamableValue(); + + (async () => { + const { partialObjectStream } = await streamObject({ + model: openai("gpt-4o-mini"), + system: `You are a helpful assistant that generates filters for a given prompt. \n + Current date is: ${new Date().toISOString().split("T")[0]} \n + Only use categories if it's specificed in the prompt. \n + ${context} + `, + schema: filterQuerySchema.pick({ + ...(validFilters.reduce((acc, filter) => { + acc[filter] = true; + return acc; + }, {}) as any), + }), + prompt, + temperature: 0.7, + }); + + for await (const partialObject of partialObjectStream) { + stream.update(partialObject); + } + + stream.done(); + })(); + + return { object: stream.value }; +} diff --git a/apps/dashboard/src/actions/schema.ts b/apps/dashboard/src/actions/schema.ts index 734d816577..4a3a83f4f1 100644 --- a/apps/dashboard/src/actions/schema.ts +++ b/apps/dashboard/src/actions/schema.ts @@ -1,3 +1,4 @@ +import { isValid } from "date-fns"; import { z } from "zod"; export const updateUserSchema = z.object({ @@ -426,3 +427,30 @@ export const assistantSettingsSchema = z.object({ }); export const requestAccessSchema = z.void(); + +export const parseDateSchema = z + .string() + .transform((v) => isValid(v)) + .refine((v) => !!v, { message: "Invalid date" }); + +export const filterQuerySchema = z.object({ + name: z.string().optional().describe("The name to search for"), + start: parseDateSchema + .optional() + .describe("The start date when to retrieve from. Return ISO-8601 format."), + end: parseDateSchema + .optional() + .describe( + "The end date when to retrieve data from. If not provided, defaults to the current date. Return ISO-8601 format.", + ), + attachments: z + .enum(["exclude", "include"]) + .optional() + .describe( + "Whether to include or exclude results with attachments or receipts.", + ), + categories: z + .array(z.string()) + .optional() + .describe("The categories to filter by"), +}); diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/account/teams/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/account/teams/page.tsx index b9bf306256..dcab9e3a3c 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/account/teams/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/account/teams/page.tsx @@ -1,6 +1,6 @@ import { TeamsTable } from "@/components/tables/teams"; import { TeamsSkeleton } from "@/components/tables/teams/table"; -import { Metadata } from "next"; +import type { Metadata } from "next"; import { Suspense } from "react"; export const metadata: Metadata = { diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/filters.ts b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/filters.ts new file mode 100644 index 0000000000..46f2326b89 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/filters.ts @@ -0,0 +1,9 @@ +export const VALID_FILTERS = [ + "name", + "attachments", + "categories", + "start", + "end", + "accounts", + "assignees", +]; 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 489dcd57d0..5bdbc079a1 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/page.tsx @@ -1,17 +1,20 @@ import { ErrorFallback } from "@/components/error-fallback"; import { TransactionsModal } from "@/components/modals/transactions-modal"; -import { SearchField } from "@/components/search-field"; import { Table } from "@/components/tables/transactions"; import { Loading } from "@/components/tables/transactions/loading"; import { TransactionsActions } from "@/components/transactions-actions"; +import { TransactionsSearchFilter } from "@/components/transactions-search-filter"; import { getCategories, getTeamBankAccounts, + getTeamMembers, } from "@midday/supabase/cached-queries"; import { cn } from "@midday/ui/cn"; import type { Metadata } from "next"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; import { Suspense } from "react"; +import { VALID_FILTERS } from "./filters"; +import { searchParamsCache } from "./search-params"; export const metadata: Metadata = { title: "Transactions | Midday", @@ -20,35 +23,67 @@ export const metadata: Metadata = { export default async function Transactions({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Record; }) { - const [accounts, categories] = await Promise.all([ + const { + q: query, + page, + attachments, + start, + end, + categories, + assignees, + statuses, + } = searchParamsCache.parse(searchParams); + + // Move this in a suspense + const [accountsData, categoriesData, teamMembersData] = await Promise.all([ getTeamBankAccounts(), getCategories(), + getTeamMembers(), ]); - const page = typeof searchParams.page === "string" ? +searchParams.page : 0; - const filter = - (searchParams?.filter && JSON.parse(searchParams.filter)) ?? {}; + const filter = { + attachments, + start, + end, + categories, + assignees, + statuses, + }; + const sort = searchParams?.sort?.split(":"); const isOpen = Boolean(searchParams.step); - const isEmpty = !accounts?.data?.length && !isOpen; + const isEmpty = !accountsData?.data?.length && !isOpen; const loadingKey = JSON.stringify({ page, filter, sort, - query: searchParams?.q, + query, }); return ( <>
- - ({ + slug: category.slug, + name: category.name, + }))} + accounts={accountsData?.data?.map((account) => ({ + id: account.id, + name: account.name, + currency: account.currency, + }))} + members={teamMembersData?.data?.map((member) => ({ + id: member?.user.id, + name: member.user?.full_name, + }))} /> +
@@ -59,7 +94,7 @@ export default async function Transactions({ page={page} sort={sort} noAccounts={isEmpty} - query={searchParams?.q} + query={query} /> 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 new file mode 100644 index 0000000000..2f10478e75 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/transactions/search-params.ts @@ -0,0 +1,23 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringLiteral, +} from "nuqs/server"; + +export const searchParamsCache = createSearchParamsCache({ + q: parseAsString, + page: parseAsInteger.withDefault(0), + attachments: parseAsStringLiteral(["exclude", "include"] as const), + start: parseAsString, + end: parseAsString, + categories: parseAsArrayOf(parseAsString), + accounts: parseAsArrayOf(parseAsString), + assignees: parseAsArrayOf(parseAsString), + statuses: parseAsStringLiteral([ + "fullfilled", + "unfulfilled", + "excluded", + ] as const), +}); diff --git a/apps/dashboard/src/components/assistant/toolbar.tsx b/apps/dashboard/src/components/assistant/toolbar.tsx index 1d6d025362..60d0431671 100644 --- a/apps/dashboard/src/components/assistant/toolbar.tsx +++ b/apps/dashboard/src/components/assistant/toolbar.tsx @@ -6,7 +6,7 @@ export function Toolbar({ onNewChat }: Props) { return ( - - + + { + handleChangePeriod({ + from: from ? formatISO(from, { representation: "date" }) : null, + to: to ? formatISO(to, { representation: "date" }) : null, + }); + }} + />
diff --git a/apps/dashboard/src/components/charts/spending-category-list.tsx b/apps/dashboard/src/components/charts/spending-category-list.tsx index 60fa848e3d..34d46bf99f 100644 --- a/apps/dashboard/src/components/charts/spending-category-list.tsx +++ b/apps/dashboard/src/components/charts/spending-category-list.tsx @@ -20,10 +20,7 @@ export function SpendingCategoryList({ categories, period }) { void; + categories?: { id: string; name: string; slug: string }[]; + accounts?: { id: string; name: string; currency: string }[]; + members?: { id: string; name: string }[]; + statusFilters: { id: string; name: string }[]; + attachmentsFilters: { id: string; name: string }[]; +}; + +export function FilterList({ + filters, + loading, + onRemove, + categories, + accounts, + members, + statusFilters, + attachmentsFilters, +}: Props) { + const renderFilter = ({ key, value }) => { + switch (key) { + case "start": { + if (key === "start" && value && filters.end) { + return `${format(new Date(value), "MMM d, yyyy")} - ${format( + new Date(filters.end), + "MMM d, yyyy", + )}`; + } + + return ( + key === "start" && value && format(new Date(value), "MMM d, yyyy") + ); + } + + case "attachments": { + return attachmentsFilters?.find((filter) => filter.id === value)?.name; + } + + case "statuses": { + return value + .map( + (status) => + statusFilters.find((filter) => filter.id === status)?.name, + ) + .join(", "); + } + + case "categories": { + return value + .map( + (slug) => + categories?.find((category) => category.slug === slug)?.name, + ) + .join(", "); + } + + case "accounts": { + return value + .map((id) => { + const account = accounts?.find((account) => account.id === id); + return `${account.name} (${account.currency})`; + }) + .join(", "); + } + + case "assignees": { + return value + .map((id) => { + const member = members?.find((member) => member.id === id); + return member?.name; + }) + .join(", "); + } + + case "q": + return value; + + default: + return null; + } + }; + + const handleOnRemove = (key: string) => { + if (key === "start" || key === "end") { + onRemove({ start: null, end: null }); + return; + } + + onRemove({ [key]: null }); + }; + + return ( + + {loading && ( +
+ + + + + + +
+ )} + + {!loading && + Object.entries(filters) + .filter(([key, value]) => value !== null && key !== "end") + .map(([key, value]) => { + return ( + + + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/filter.tsx b/apps/dashboard/src/components/filter.tsx deleted file mode 100644 index 0f53bcbfcb..0000000000 --- a/apps/dashboard/src/components/filter.tsx +++ /dev/null @@ -1,538 +0,0 @@ -"use client"; - -import { useI18n } from "@/locales/client"; -import { Button } from "@midday/ui/button"; -import { Checkbox } from "@midday/ui/checkbox"; -import { cn } from "@midday/ui/cn"; -import { Input } from "@midday/ui/input"; -import { Label } from "@midday/ui/label"; -import { MonthRangePicker } from "@midday/ui/month-range-picker"; -import { Popover, PopoverContent, PopoverTrigger } from "@midday/ui/popover"; -import { RadioGroup, RadioGroupItem } from "@midday/ui/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@midday/ui/select"; -import * as Tabs from "@radix-ui/react-tabs"; -import { format } from "date-fns"; -import { ChevronDown, ChevronRight, Trash2, X } from "lucide-react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; - -export enum SectionType { - date = "date", - checkbox = "checkbox", - search = "search", - radio = "radio", -} - -type SelectedOption = { - filter: string; - value: string; -}; - -type Option = { - id: string; - label?: string; - renderLabel?: (value: TVal) => React.ReactNode; - translationKey?: string; - from?: Date; - to?: Date; - description?: string; - icon?: any; -}; - -type Section = { - id: string; - label?: string; - translationKey?: string; - type: SectionType; - options: Option[]; - storage?: string; - placeholder?: string; - icon?: any; -}; - -type Props = { - sections: Section[]; -}; - -export function Filter({ sections }: Props) { - const t = useI18n(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - const router = useRouter(); - const [activeId, setActiveId] = useState(sections?.at(0)?.id as string); - const [isOpen, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [recentSearch, setRecentSearch] = useState([]); - const filters = searchParams?.get("filter") - ? JSON.parse(searchParams?.get("filter") as string) - : []; - - const setFilters = (query) => { - const params = new URLSearchParams(searchParams); - params.set("filter", JSON.stringify(query)); - params.delete("page"); - - if (Object.keys(query).length === 0) { - params.delete("filter"); - } - - router.replace(`${pathname}?${params.toString()}`); - }; - - useEffect(() => { - const storageKey = sections.find( - (section) => section.id === activeId - )?.storage; - const saved = storageKey && localStorage.getItem(storageKey); - - if (saved) { - setRecentSearch(JSON.parse(saved)); - } - }, [activeId]); - - const handleDateChange = (range = {}) => { - const prevRange = filters[activeId]; - - if (range.from || range.to) { - setFilters({ - ...filters, - date: { ...prevRange, ...range }, - }); - } else { - delete filters.date; - setFilters(filters); - } - }; - - const handleDeleteFilter = (filter: string) => { - delete filters[filter]; - setFilters(filters); - }; - - const toggleFilter = (option: SelectedOption) => { - let query; - - const filter = option.filter.toLowerCase(); - const value = option.value.toLowerCase(); - const section = filters[filter]; - - if (section?.includes(value)) { - query = { - ...filters, - [filter]: section.filter((item: string) => item !== value), - }; - } else { - query = { - ...filters, - [filter]: [...(filters[filter] ?? []), value], - }; - } - - if (query && !query[filter].length) { - delete query[filter]; - } - - if (option.single) { - const defaultValue = sections.find( - (section) => section.id === filter - )?.defaultValue; - - if (value === defaultValue) { - delete query[filter]; - } else { - query = { - ...filters, - [filter]: value, - }; - } - } - - setFilters(query); - }; - - const handleOnSearch = ( - evt: React.KeyboardEvent, - storage?: string - ) => { - if (evt.key === "Enter") { - setOpen(false); - if (storage) { - handleRecentSearch(storage, query); - } - - if (query) { - setFilters({ ...filters, search: query }); - } else { - delete filters.search; - setFilters(filters); - } - } - }; - - const handleSelectRecentSearch = (value: string) => { - setOpen(false); - setFilters({ ...filters, search: value }); - }; - - const handleOpenSection = (id?: string) => { - setOpen(true); - - if (id) { - setActiveId(id); - } - }; - - const handleRecentSearch = (storage: string, value: string) => { - if (!recentSearch.includes(value)) { - const updated = [value, ...recentSearch].slice(0, 6); - - setRecentSearch(updated); - localStorage.setItem(storage, JSON.stringify(updated)); - } - }; - - const deleteRecentSearch = (storage?: string) => { - setRecentSearch([]); - if (storage) { - localStorage.removeItem(storage); - } - }; - - const renderFilter = (section: Section) => { - const filter = filters[section.id]; - - switch (section.type) { - case SectionType.date: { - if (filter.from && filter.to) { - return `${format(new Date(filter.from), "MMM d, yyyy")} - ${format( - new Date(filter.to), - "MMM d, yyyy" - )}`; - } - - return filter.from && format(new Date(filter.from), "MMM d, yyyy"); - } - case SectionType.search: { - return `Anything matching "${filter}"`; - } - - case SectionType.radio: { - return section.options.find((option) => option.id === filter)?.label; - } - - default: { - if (filter.length > 1) { - return `${section.label} (${filter.length})`; - } - - if (filter.length) { - const option = section?.options?.find((o) => o.id === filter.at(0)); - - return option?.renderLabel?.(option) ?? option?.translationKey - ? t(option?.translationKey) - : option?.label; - } - } - } - }; - - return ( -
-
- {Object.keys(filters).map((optionId) => { - const section = sections.find((o) => o.id === optionId); - - return ( -
- -
- ); - })} -
- - {!Object.keys(filters).length && ( - No filters applied - )} - -
- - - - - - - - - {sections?.map(({ id, label, icon: Icon }) => { - const isActive = activeId === id; - - return ( - - - - ); - })} - - - {sections?.map((section) => { - if (section.type === SectionType.date) { - return ( - - - - - - ); - } - - if (section.type === SectionType.search) { - return ( - - handleOnSearch(evt, section?.storage)} - onChange={(evt) => setQuery(evt.target.value)} - value={query} - defaultValue={filters.search} - /> - -
-
-

- Recent -

- {recentSearch.length > 0 && ( - - )} -
- -
- {recentSearch?.map((recent) => ( -
- - handleSelectRecentSearch(recent) - } - checked={filters.search === recent} - /> -
- -
-
- ))} - - {!recentSearch.length && ( -
-

- No recent searches -

-
- )} -
-
-
- ); - } - - if (section.type === SectionType.checkbox) { - return ( - -
- {sections - ?.filter( - (section) => section.type === SectionType.checkbox - ) - .find((section) => section.id === activeId) - ?.options?.map((option) => { - const isChecked = Boolean( - filters[activeId]?.includes(option.id.toLowerCase()) - ); - - return ( -
- - toggleFilter({ - filter: activeId!, - value: option.id, - }) - } - /> -
- -

- {option?.description} -

-
-
- ); - })} -
-
- ); - } - - if (section.type === SectionType.radio) { - return ( - - - toggleFilter({ - filter: activeId!, - value, - single: true, - }) - } - > - {sections - ?.filter( - (section) => section.type === SectionType.radio - ) - .find((section) => section.id === activeId) - ?.options?.map((option) => { - return ( -
- - -
- ); - })} -
-
- ); - } - })} -
-
-
-
- ); -} diff --git a/apps/dashboard/src/components/hot-keys.tsx b/apps/dashboard/src/components/hot-keys.tsx index cb7f3ea97c..205c1962f1 100644 --- a/apps/dashboard/src/components/hot-keys.tsx +++ b/apps/dashboard/src/components/hot-keys.tsx @@ -12,11 +12,6 @@ export function HotKeys() { router.refresh(); }; - useHotkeys("meta+s", (evt) => { - evt.preventDefault(); - router.push("/settings"); - }); - useHotkeys("ctrl+m", (evt) => { evt.preventDefault(); router.push("/settings/members"); diff --git a/apps/dashboard/src/components/search-field.tsx b/apps/dashboard/src/components/search-field.tsx index 009568a213..1ad3df3146 100644 --- a/apps/dashboard/src/components/search-field.tsx +++ b/apps/dashboard/src/components/search-field.tsx @@ -29,7 +29,7 @@ export function SearchField({ placeholder }: Props) { }; return ( -
+
void; + headless?: boolean; }; function transformCategory(category) { @@ -31,13 +33,15 @@ function transformCategory(category) { }; } -export function SelectCategory({ selected, onChange }: Props) { +export function SelectCategory({ selected, onChange, headless }: Props) { const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); const supabase = createClient(); useEffect(() => { async function fetchData() { const { data: userData } = await getCurrentUserTeamQuery(supabase); + if (userData?.team_id) { const response = await getCategoriesQuery(supabase, { teamId: userData.team_id, @@ -48,6 +52,8 @@ export function SelectCategory({ selected, onChange }: Props) { setData(response.data.map(transformCategory)); } } + + setIsLoading(false); } if (!data.length) { @@ -68,8 +74,17 @@ export function SelectCategory({ selected, onChange }: Props) { const selectedValue = selected ? transformCategory(selected) : undefined; + if (!selected && isLoading) { + return ( +
+ +
+ ); + } + return ( { - createCategories.execute({ - categories: [ - { - name: value, - color: getColorFromName(value), - }, - ], - }); + if (!headless) { + createCategories.execute({ + categories: [ + { + name: value, + color: getColorFromName(value), + }, + ], + }); + } }} renderSelectedItem={(selectedItem) => (
@@ -102,12 +119,14 @@ export function SelectCategory({ selected, onChange }: Props) {
)} renderOnCreate={(value) => { - return ( -
- - {`Create "${value}"`} -
- ); + if (!headless) { + return ( +
+ + {`Create "${value}"`} +
+ ); + } }} renderListItem={({ item }) => { return ( diff --git a/apps/dashboard/src/components/sheets/tracker-create-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-create-sheet.tsx index a89628f308..615e0c4ae1 100644 --- a/apps/dashboard/src/components/sheets/tracker-create-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-create-sheet.tsx @@ -3,16 +3,16 @@ import { createProjectAction } from "@/actions/project/create-project-action"; import { createProjectSchema } from "@/actions/schema"; import { TrackerProjectForm } from "@/components/forms/tracker-project-form"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { Drawer, DrawerContent, DrawerHeader } from "@midday/ui/drawer"; +import { useMediaQuery } from "@midday/ui/hooks"; 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 from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import type { z } from "zod"; export function TrackerCreateSheet({ currencyCode, setParams, isOpen }) { const { toast } = useToast(); diff --git a/apps/dashboard/src/components/sheets/tracker-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-sheet.tsx index 79547e253c..40e1498291 100644 --- a/apps/dashboard/src/components/sheets/tracker-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-sheet.tsx @@ -3,11 +3,11 @@ import { updateEntriesAction } from "@/actions/project/update-entries-action"; import { TrackerMonthGraph } from "@/components/tracker-month-graph"; import { TrackerSelect } from "@/components/tracker-select"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { secondsToHoursAndMinutes } from "@/utils/format"; import { createClient } from "@midday/supabase/client"; import { getTrackerRecordsByRangeQuery } from "@midday/supabase/queries"; import { Drawer, DrawerContent, DrawerHeader } from "@midday/ui/drawer"; +import { useMediaQuery } from "@midday/ui/hooks"; import { ScrollArea } from "@midday/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader } from "@midday/ui/sheet"; import { useToast } from "@midday/ui/use-toast"; diff --git a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx index 40e68f54f2..94ea7c9454 100644 --- a/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx +++ b/apps/dashboard/src/components/sheets/tracker-update-sheet.tsx @@ -4,7 +4,6 @@ import { deleteProjectAction } from "@/actions/project/delete-project-action"; import { updateProjectAction } from "@/actions/project/update-project-action"; import { updateProjectSchema } from "@/actions/schema"; import { TrackerProjectForm } from "@/components/forms/tracker-project-form"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertDialog, @@ -24,6 +23,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@midday/ui/dropdown-menu"; +import { useMediaQuery } from "@midday/ui/hooks"; import { Icons } from "@midday/ui/icons"; import { ScrollArea } from "@midday/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader } from "@midday/ui/sheet"; @@ -78,7 +78,7 @@ export function TrackerUpdateSheet({ currencyCode, data, isOpen, setParams }) { const handleShareURL = async (id: string) => { try { await navigator.clipboard.writeText( - `${window.location.origin}/tracker?projectId=${id}` + `${window.location.origin}/tracker?projectId=${id}`, ); toast({ diff --git a/apps/dashboard/src/components/sheets/transaction-sheet.tsx b/apps/dashboard/src/components/sheets/transaction-sheet.tsx index d5247eea69..41529622a8 100644 --- a/apps/dashboard/src/components/sheets/transaction-sheet.tsx +++ b/apps/dashboard/src/components/sheets/transaction-sheet.tsx @@ -1,6 +1,6 @@ import type { UpdateTransactionValues } from "@/actions/schema"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { Drawer, DrawerContent } from "@midday/ui/drawer"; +import { useMediaQuery } from "@midday/ui/hooks"; import { Sheet, SheetContent } from "@midday/ui/sheet"; import React from "react"; import { TransactionDetails } from "../transaction-details"; @@ -12,7 +12,7 @@ type Props = { ids?: string[]; updateTransaction: ( values: UpdateTransactionValues, - optimisticData: any + optimisticData: any, ) => void; }; diff --git a/apps/dashboard/src/components/tables/transactions/bottom-bar.tsx b/apps/dashboard/src/components/tables/transactions/bottom-bar.tsx index af84fec46e..7200bbd50e 100644 --- a/apps/dashboard/src/components/tables/transactions/bottom-bar.tsx +++ b/apps/dashboard/src/components/tables/transactions/bottom-bar.tsx @@ -34,7 +34,7 @@ export function BottomBar({ count, show, totalAmount }: Props) { amount: total?.amount, currency: total.currency, locale, - }) + }), ) .join(", "); @@ -45,7 +45,7 @@ export function BottomBar({ count, show, totalAmount }: Props) { animate={{ y: show ? 0 : 100 }} initial={{ y: 100 }} > -
+
diff --git a/apps/dashboard/src/components/tables/transactions/data-table.tsx b/apps/dashboard/src/components/tables/transactions/data-table.tsx index 7e203d8348..aad440df03 100644 --- a/apps/dashboard/src/components/tables/transactions/data-table.tsx +++ b/apps/dashboard/src/components/tables/transactions/data-table.tsx @@ -67,7 +67,7 @@ export function DataTable({ (hasFilters && !selectedRows) || (query && !selectedRows); const [columnVisibility, setColumnVisibility] = useState( - initialColumnVisibility ?? {} + initialColumnVisibility ?? {}, ); const updateTransaction = useAction(updateTransactionAction, { @@ -92,7 +92,7 @@ export function DataTable({ const handleUpdateTransaction = ( values: UpdateTransactionValues, - optimisticData?: any + optimisticData?: any, ) => { setData((prev) => { return prev.map((item) => { @@ -140,7 +140,7 @@ export function DataTable({ const handleCopyUrl = async (id: string) => { try { await navigator.clipboard.writeText( - `${window.location.origin}/transactions?id=${id}` + `${window.location.origin}/transactions?id=${id}`, ); toast({ @@ -189,7 +189,7 @@ export function DataTable({ }; const selectedTransaction = data.find( - (transaction) => transaction?.id === transactionId + (transaction) => transaction?.id === transactionId, ); useEffect(() => { @@ -257,7 +257,7 @@ export function DataTable({ cell.column.id === "assigned" || cell.column.id === "method" || cell.column.id === "status") && - "hidden md:table-cell" + "hidden md:table-cell", )} onClick={() => { if ( diff --git a/apps/dashboard/src/components/tables/transactions/export-bar.tsx b/apps/dashboard/src/components/tables/transactions/export-bar.tsx index 5d34edf7bf..ae4c896bb7 100644 --- a/apps/dashboard/src/components/tables/transactions/export-bar.tsx +++ b/apps/dashboard/src/components/tables/transactions/export-bar.tsx @@ -47,7 +47,7 @@ export function ExportBar({ selected, deselectAll, transactionIds }: Props) { animate={{ y: isOpen ? 0 : 100 }} initial={{ y: 100 }} > -
+
{selected} selected
diff --git a/apps/dashboard/src/components/tables/transactions/index.tsx b/apps/dashboard/src/components/tables/transactions/index.tsx index 90e4b9ea73..2e87b1cc7d 100644 --- a/apps/dashboard/src/components/tables/transactions/index.tsx +++ b/apps/dashboard/src/components/tables/transactions/index.tsx @@ -14,13 +14,13 @@ type Props = { page: number; sort: any; noAccounts: boolean; - query?: string; + query: string | null; }; export async function Table({ filter, page, sort, noAccounts, query }: Props) { - const hasFilters = Object.keys(filter).length > 0; + const hasFilters = Object.values(filter).some((value) => value !== null); const initialColumnVisibility = JSON.parse( - cookies().get(Cookies.TransactionsColumns)?.value || "[]" + cookies().get(Cookies.TransactionsColumns)?.value || "[]", ); // NOTE: When we have a filter we want to show all results so users can select @@ -30,7 +30,7 @@ export async function Table({ filter, page, sort, noAccounts, query }: Props) { from: 0, filter, sort, - searchQuery: query, + searchQuery: query ?? undefined, }); const { data, meta } = transactions ?? {}; @@ -43,7 +43,7 @@ export async function Table({ filter, page, sort, noAccounts, query }: Props) { from: from + 1, filter, sort, - searchQuery: query, + searchQuery: query ?? undefined, }); } @@ -60,7 +60,7 @@ export async function Table({ filter, page, sort, noAccounts, query }: Props) { } const hasNextPage = Boolean( - meta?.count && meta.count / (page + 1) > pageSize + meta?.count && meta.count / (page + 1) > pageSize, ); return ( diff --git a/apps/dashboard/src/components/transactions-actions.tsx b/apps/dashboard/src/components/transactions-actions.tsx index 1e8861db1c..79990ce92c 100644 --- a/apps/dashboard/src/components/transactions-actions.tsx +++ b/apps/dashboard/src/components/transactions-actions.tsx @@ -2,8 +2,6 @@ import { deleteTransactionsAction } from "@/actions/delete-transactions-action"; import { ColumnVisibility } from "@/components/column-visibility"; -import { Filter } from "@/components/filter"; -import { transactionSections } from "@/components/tables/transactions/filters"; import { useTransactionsStore } from "@/store/transactions"; import { AlertDialog, @@ -23,15 +21,9 @@ import { Loader2 } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { BulkActions } from "./bulk-actions"; -type Props = { - categories: { id: string; name: string; slug: string }[] | null; - accounts: { id: string; name: string; currency?: string }[] | null; -}; - -export function TransactionsActions({ categories, accounts }: Props) { +export function TransactionsActions() { const { toast } = useToast(); const { transactionIds, canDelete } = useTransactionsStore(); - const sections = transactionSections({ categories, accounts }); const deleteTransactions = useAction(deleteTransactionsAction, { onError: () => { @@ -100,7 +92,6 @@ export function TransactionsActions({ categories, accounts }: Props) { return (
-
); diff --git a/apps/dashboard/src/components/transactions-search-filter.tsx b/apps/dashboard/src/components/transactions-search-filter.tsx new file mode 100644 index 0000000000..71241c5f62 --- /dev/null +++ b/apps/dashboard/src/components/transactions-search-filter.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { generateFilters } from "@/actions/ai/filters/generate-filters"; +import { Calendar } from "@midday/ui/calendar"; +import { cn } from "@midday/ui/cn"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@midday/ui/dropdown-menu"; +import { Icons } from "@midday/ui/icons"; +import { Input } from "@midday/ui/input"; +import { readStreamableValue } from "ai/rsc"; +import { formatISO } from "date-fns"; +import { + parseAsArrayOf, + parseAsString, + parseAsStringLiteral, + useQueryStates, +} from "nuqs"; +import { useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { FilterList } from "./filter-list"; +import { SelectCategory } from "./select-category"; + +type Props = { + placeholder: string; + validFilters: string[]; + categories?: { + id: string; + slug: string; + name: string; + }[]; + accounts?: { + id: string; + name: string; + currency: string; + }[]; + members?: { + id: string; + name: string; + }[]; +}; + +const defaultSearch = { + q: null, + attachments: null, + start: null, + end: null, + categories: null, + accounts: null, + assignees: null, + statuses: null, +}; + +const statusFilters = [ + { id: "fullfilled", name: "Fulfilled" }, + { id: "unfulfilled", name: "Unfulfilled" }, + { id: "excluded", name: "Excluded" }, +]; + +const attachmentsFilters = [ + { id: "include", name: "Has attachments" }, + { id: "exclude", name: "No attachments" }, +]; + +const PLACEHOLDERS = [ + "Software and taxes last month", + "Income last year", + "Software last Q4", + "From Google without receipt", + "Search or filter", + "Without receipts this month", +]; + +const placeholder = + PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)]; + +export function TransactionsSearchFilter({ + validFilters, + categories, + accounts, + members, +}: Props) { + const [prompt, setPrompt] = useState(""); + const inputRef = useRef(null); + const [streaming, setStreaming] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const [filters, setFilters] = useQueryStates( + { + q: parseAsString, + attachments: parseAsStringLiteral(["exclude", "include"] as const), + start: parseAsString, + end: parseAsString, + categories: parseAsArrayOf(parseAsString), + accounts: parseAsArrayOf(parseAsString), + assignees: parseAsArrayOf(parseAsString), + statuses: parseAsArrayOf( + parseAsStringLiteral([ + "fullfilled", + "unfulfilled", + "excluded", + ] as const), + ), + }, + { + shallow: false, + }, + ); + + useHotkeys( + "esc", + () => { + setPrompt(""); + setFilters(defaultSearch); + setIsOpen(false); + }, + { + enableOnFormTags: true, + }, + ); + + useHotkeys("meta+s", (evt) => { + evt.preventDefault(); + inputRef.current?.focus(); + }); + + useHotkeys("meta+f", (evt) => { + evt.preventDefault(); + setIsOpen((prev) => !prev); + }); + + const handleSearch = (evt: React.ChangeEvent) => { + const value = evt.target.value; + + if (value) { + setPrompt(value); + } else { + setFilters(defaultSearch); + setPrompt(""); + } + }; + + const handleSubmit = async () => { + // If the user is typing a query with multiple words, we want to stream the results + if (prompt.split(" ").length > 1) { + setStreaming(true); + + const { object } = await generateFilters( + prompt, + validFilters, + categories + ? `Categories: ${categories?.map((category) => category.name).join(", ")} + ` + : "", + ); + + let finalObject = {}; + + for await (const partialObject of readStreamableValue(object)) { + if (partialObject) { + finalObject = { + ...finalObject, + ...partialObject, + categories: + partialObject?.categories?.map( + (name: string) => + categories?.find((category) => category.name === name)?.slug, + ) ?? null, + q: partialObject?.name ?? null, + }; + } + } + + setFilters({ + q: null, + ...finalObject, + }); + + setStreaming(false); + } else { + setFilters({ q: prompt.length > 0 ? prompt : null }); + } + }; + + const hasValidFilters = + Object.entries(filters).filter( + ([key, value]) => value !== null && key !== "q", + ).length > 0; + + return ( + +
+
{ + e.preventDefault(); + handleSubmit(); + }} + > + + + + + + + + + +
+ + + + + + + Date + + + + { + setFilters({ + start: from + ? formatISO(from, { representation: "date" }) + : null, + end: to + ? formatISO(to, { representation: "date" }) + : null, + }); + }} + /> + + + + + + + + + + Status + + + + {statusFilters.map(({ id, name }) => ( + { + setFilters({ + statuses: filters?.statuses?.includes(id) + ? filters.statuses.filter((s) => s !== id).length > 0 + ? filters.statuses.filter((s) => s !== id) + : null + : [...(filters?.statuses ?? []), id], + }); + }} + > + {name} + + ))} + + + + + + + + + + + Attachments + + + + {attachmentsFilters.map(({ id, name }) => ( + { + setFilters({ + attachments: id, + }); + }} + > + {name} + + ))} + + + + + + + + + + + Categories + + + + { + setFilters({ + categories: filters?.categories?.includes(selected.slug) + ? filters.categories.filter((s) => s !== selected.slug) + .length > 0 + ? filters.categories.filter( + (s) => s !== selected.slug, + ) + : null + : [...(filters?.categories ?? []), selected.slug], + }); + }} + headless + /> + + + + + + + + + + + Accounts + + + + {accounts?.map((account) => ( + { + setFilters({ + accounts: filters?.accounts?.includes(account.id) + ? filters.accounts.filter((s) => s !== account.id) + .length > 0 + ? filters.accounts.filter((s) => s !== account.id) + : null + : [...(filters?.accounts ?? []), account.id], + }); + }} + > + {account.name} ({account.currency}) + + ))} + + + + + +
+ ); +} diff --git a/apps/dashboard/src/styles/globals.css b/apps/dashboard/src/styles/globals.css index 0d0d4bf084..02f9004485 100644 --- a/apps/dashboard/src/styles/globals.css +++ b/apps/dashboard/src/styles/globals.css @@ -221,4 +221,4 @@ html.todesktop-panel body { .remove-arrow { appearance: textfield; -} \ No newline at end of file +} diff --git a/apps/website/src/components/assistant/chat.tsx b/apps/website/src/components/assistant/chat.tsx index 2ed1abaef8..0a23d94d37 100644 --- a/apps/website/src/components/assistant/chat.tsx +++ b/apps/website/src/components/assistant/chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEnterSubmit } from "@/hooks/use-enter-submit"; +import { useEnterSubmit } from "@midday/ui/hooks"; import { ScrollArea } from "@midday/ui/scroll-area"; import { Textarea } from "@midday/ui/textarea"; import { motion } from "framer-motion"; @@ -13,7 +13,7 @@ import { chatExamples } from "./examples"; import { Footer } from "./footer"; import { BotCard, SignUpCard, UserMessage } from "./messages"; -export function Chat({ messages, submitMessage, onNewChat, input, setInput }) { +export function Chat({ messages, submitMessage, input, setInput }) { const { formRef, onKeyDown } = useEnterSubmit(); const [isVisible, setVisible] = useState(false); @@ -36,7 +36,7 @@ export function Chat({ messages, submitMessage, onNewChat, input, setInput }) { ]); const content = chatExamples.find( - (example) => example.title === input + (example) => example.title === input, )?.content; if (content) { @@ -57,7 +57,7 @@ export function Chat({ messages, submitMessage, onNewChat, input, setInput }) { ), }, ]), - 500 + 500, ); } else { setTimeout( @@ -70,7 +70,7 @@ export function Chat({ messages, submitMessage, onNewChat, input, setInput }) { display: , }, ]), - 200 + 200, ); } }; diff --git a/apps/website/src/components/card-stack.tsx b/apps/website/src/components/card-stack.tsx index d851917f42..a6c30edd97 100644 --- a/apps/website/src/components/card-stack.tsx +++ b/apps/website/src/components/card-stack.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { useMediaQuery } from "@midday/ui/hooks"; import { Tooltip, TooltipContent, diff --git a/apps/website/src/components/pitch/section-demo.tsx b/apps/website/src/components/pitch/section-demo.tsx index 9552a49acf..8b259779e0 100644 --- a/apps/website/src/components/pitch/section-demo.tsx +++ b/apps/website/src/components/pitch/section-demo.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { Button } from "@midday/ui/button"; +import { useMediaQuery } from "@midday/ui/hooks"; import { Icons } from "@midday/ui/icons"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -27,7 +27,7 @@ export function SectionDemo({ playVideo }: Props) { () => { togglePlay(); }, - [] + [], ); useHotkeys( @@ -35,7 +35,7 @@ export function SectionDemo({ playVideo }: Props) { () => { handleRestart(); }, - [playerRef] + [playerRef], ); useEffect(() => { diff --git a/apps/website/src/components/section-video.tsx b/apps/website/src/components/section-video.tsx index d72d31f740..21f4b51433 100644 --- a/apps/website/src/components/section-video.tsx +++ b/apps/website/src/components/section-video.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { Button } from "@midday/ui/button"; +import { useMediaQuery } from "@midday/ui/hooks"; import { Icons } from "@midday/ui/icons"; import { motion } from "framer-motion"; import dynamic from "next/dynamic"; diff --git a/apps/website/src/hooks/use-media-query.ts b/apps/website/src/hooks/use-media-query.ts deleted file mode 100644 index 954e2e51e6..0000000000 --- a/apps/website/src/hooks/use-media-query.ts +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export function useMediaQuery(query: string) { - const [value, setValue] = useState(false); - - useEffect(() => { - function onChange(event: MediaQueryListEvent) { - setValue(event.matches); - } - - const result = matchMedia(query); - result.addEventListener("change", onChange); - setValue(result.matches); - - return () => result.removeEventListener("change", onChange); - }, [query]); - - return value; -} diff --git a/bun.lockb b/bun.lockb index a1481fa9ef..3bd9f9a61f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/email/emails/transactions.tsx b/packages/email/emails/transactions.tsx index a865e800e8..0314331149 100644 --- a/packages/email/emails/transactions.tsx +++ b/packages/email/emails/transactions.tsx @@ -234,12 +234,7 @@ export const TransactionsEmail = ({
diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index 52856ce2f6..2fed96ecde 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -163,15 +163,14 @@ export type GetTransactionsParams = { }; searchQuery?: string; filter?: { - status?: "fullfilled" | "unfullfilled" | "excluded"; + statuses?: string[]; attachments?: "include" | "exclude"; categories?: string[]; accounts?: string[]; + assignees?: string[]; type?: "income" | "expense"; - date?: { - from?: string; - to?: string; - }; + start?: string; + end?: string; }; }; @@ -181,12 +180,14 @@ export async function getTransactionsQuery( ) { const { from = 0, to, filter, sort, teamId, searchQuery } = params; const { - date = {}, - status, + statuses, attachments, categories, type, accounts, + start, + end, + assignees, } = filter || {}; const columns = [ @@ -233,9 +234,9 @@ export async function getTransactionsQuery( .order("created_at", { ascending: false }); } - if (date?.from && date?.to) { - query.gte("date", date.from); - query.lte("date", date.to); + if (start && end) { + query.gte("date", start); + query.lte("date", end); } if (searchQuery) { @@ -246,15 +247,15 @@ export async function getTransactionsQuery( } } - if (status?.includes("fullfilled") || attachments === "include") { + if (statuses?.includes("fullfilled") || attachments === "include") { query.eq("is_fulfilled", true); } - if (status?.includes("unfulfilled") || attachments === "exclude") { + if (statuses?.includes("unfulfilled") || attachments === "exclude") { query.eq("is_fulfilled", false); } - if (status?.includes("excluded")) { + if (statuses?.includes("excluded")) { query.eq("status", "excluded"); } else { query.or("status.eq.pending,status.eq.posted,status.eq.completed"); @@ -286,6 +287,10 @@ export async function getTransactionsQuery( query.in("bank_account_id", accounts); } + if (assignees?.length) { + query.in("assigned_id", assignees); + } + const { data, count } = await query.range(from, to); const totalAmount = data diff --git a/packages/ui/package.json b/packages/ui/package.json index fb7574095e..a4af342a53 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,13 +13,13 @@ "build": "storybook build" }, "devDependencies": { - "@storybook/addon-essentials": "^8.2.7", - "@storybook/addon-links": "^8.2.7", - "@storybook/react-vite": "^8.2.7", + "@storybook/addon-essentials": "^8.2.8", + "@storybook/addon-links": "^8.2.8", + "@storybook/react-vite": "^8.2.8", "autoprefixer": "^10.4.20", "react": "^18.3.1", "react-dom": "^18.3.1", - "storybook": "^8.2.7", + "storybook": "^8.2.8", "typescript": "^5.5.4" }, "exports": { @@ -49,7 +49,6 @@ "./input": "./src/components/input.tsx", "./input-otp": "./src/components/input-otp.tsx", "./label": "./src/components/label.tsx", - "./month-range-picker": "./src/components/month-range-picker.tsx", "./navigation-menu": "./src/components/navigation-menu.tsx", "./popover": "./src/components/popover.tsx", "./postcss": "./postcss.config.js", @@ -70,10 +69,11 @@ "./toaster": "./src/components/toaster.tsx", "./tooltip": "./src/components/tooltip.tsx", "./use-toast": "./src/components/use-toast.tsx", - "./cn": "./src/utils/cn.ts" + "./cn": "./src/utils/cn.ts", + "./hooks": "./src/hooks/index.ts" }, "dependencies": { - "@mui/icons-material": "^5.16.6", + "@mui/icons-material": "^5.16.7", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", @@ -98,8 +98,8 @@ "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", "@storybook/addon-styling": "^1.3.7", - "@storybook/addon-themes": "^8.2.7", - "@storybook/manager-api": "^8.2.7", + "@storybook/addon-themes": "^8.2.8", + "@storybook/manager-api": "^8.2.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "0.2.1", @@ -107,13 +107,13 @@ "embla-carousel-react": "^8.1.8", "input-otp": "^1.2.4", "jsonfile": "^6.1.0", - "lucide-react": "^0.424.0", - "postcss": "^8.4.40", - "react-day-picker": "^9.0.6", + "lucide-react": "^0.427.0", + "postcss": "^8.4.41", + "react-day-picker": "8.10.1", "react-icons": "^5.2.1", "storybook-dark-mode": "^4.0.2", "tailwind-merge": "2.4.0", - "tailwindcss": "^3.4.7", + "tailwindcss": "^3.4.9", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1" } diff --git a/packages/ui/src/components/animated-size-container.tsx b/packages/ui/src/components/animated-size-container.tsx new file mode 100644 index 0000000000..2b11d1bc75 --- /dev/null +++ b/packages/ui/src/components/animated-size-container.tsx @@ -0,0 +1,66 @@ +import { motion } from "framer-motion"; +import { + type ComponentPropsWithoutRef, + type PropsWithChildren, + forwardRef, + useRef, +} from "react"; +import { useResizeObserver } from "../hooks"; +import { cn } from "../utils"; + +type AnimatedSizeContainerProps = PropsWithChildren<{ + width?: boolean; + height?: boolean; +}> & + Omit, "animate" | "children">; + +/** + * A container with animated width and height (each optional) based on children dimensions + */ +const AnimatedSizeContainer = forwardRef< + HTMLDivElement, + AnimatedSizeContainerProps +>( + ( + { + width = false, + height = false, + className, + transition, + children, + ...rest + }: AnimatedSizeContainerProps, + forwardedRef, + ) => { + const containerRef = useRef(null); + const resizeObserverEntry = useResizeObserver(containerRef); + + return ( + +
+ {children} +
+
+ ); + }, +); + +AnimatedSizeContainer.displayName = "AnimatedSizeContainer"; + +export { AnimatedSizeContainer }; diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx index aa63dea02f..eb99130623 100644 --- a/packages/ui/src/components/calendar.tsx +++ b/packages/ui/src/components/calendar.tsx @@ -1,7 +1,7 @@ "use client"; -import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; -import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type * as React from "react"; import { DayPicker } from "react-day-picker"; import { cn } from "../utils"; import { buttonVariants } from "./button"; @@ -26,31 +26,26 @@ function Calendar({ nav: "space-x-1 flex items-center", nav_button: cn( buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", ), nav_button_previous: "absolute left-1", nav_button_next: "absolute right-1", table: "w-full border-collapse space-y-1", head_row: "flex", head_cell: - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent", - props.mode === "range" - ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md" - ), + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", day: cn( buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + "h-9 w-9 p-0 font-normal aria-selected:opacity-100", ), - day_range_start: "day-range-start", day_range_end: "day-range-end", day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", day_today: "bg-accent text-accent-foreground", - day_outside: "text-muted-foreground opacity-50", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", day_disabled: "text-muted-foreground opacity-50", day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", @@ -58,8 +53,8 @@ function Calendar({ ...classNames, }} components={{ - IconLeft: ({ ...props }) => , - IconRight: ({ ...props }) => , + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , }} {...props} /> diff --git a/packages/ui/src/components/combobox-dropdown.tsx b/packages/ui/src/components/combobox-dropdown.tsx index 8882be05f2..361b533a4d 100644 --- a/packages/ui/src/components/combobox-dropdown.tsx +++ b/packages/ui/src/components/combobox-dropdown.tsx @@ -37,9 +37,11 @@ type Props = { popoverProps?: React.ComponentProps; disabled?: boolean; onCreate?: (value: string) => void; + headless?: boolean; }; export function ComboboxDropdown({ + headless, placeholder, searchPlaceholder, items, @@ -62,7 +64,7 @@ export function ComboboxDropdown({ const selectedItem = incomingSelectedItem ?? internalSelectedItem; const filteredItems = items.filter((item) => - item.label.toLowerCase().includes(inputValue.toLowerCase()) + item.label.toLowerCase().includes(inputValue.toLowerCase()), ); const showCreate = @@ -70,6 +72,82 @@ export function ComboboxDropdown({ inputValue && !items?.find((o) => o.label.toLowerCase() === inputValue.toLowerCase()); + const Component = ( + + + + {emptyResults ?? "No item found"} + + + {filteredItems.map((item) => { + const isChecked = selectedItem?.id === item.id; + + return ( + { + const foundItem = items.find((item) => item.id === id); + + if (!foundItem) { + return; + } + + onSelect(foundItem); + setInternalSelectedItem(foundItem); + setOpen(false); + }} + > + {renderListItem ? ( + renderListItem({ isChecked, item }) + ) : ( + <> + + {item.label} + + )} + + ); + })} + + {showCreate && ( + { + onCreate(inputValue); + setOpen(false); + setInputValue(""); + }} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + {renderOnCreate ? renderOnCreate(inputValue) : null} + + )} + + + + ); + + if (headless) { + return Component; + } + return ( @@ -98,75 +176,7 @@ export function ComboboxDropdown({ ...popoverProps?.style, }} > - - - - {emptyResults ?? "No item found"} - - - {filteredItems.map((item) => { - const isChecked = selectedItem?.id === item.id; - - return ( - { - const foundItem = items.find((item) => item.id === id); - - if (!foundItem) { - return; - } - - onSelect(foundItem); - setInternalSelectedItem(foundItem); - setOpen(false); - }} - > - {renderListItem ? ( - renderListItem({ isChecked, item }) - ) : ( - <> - - {item.label} - - )} - - ); - })} - - {showCreate && ( - { - onCreate(inputValue); - setOpen(false); - setInputValue(""); - }} - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - > - {renderOnCreate ? renderOnCreate(inputValue) : null} - - )} - - - + {Component} ); diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx index 9f2c845c4a..2e334d171c 100644 --- a/packages/ui/src/components/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef< className={cn( "flex cursor-default select-none items-center px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", inset && "pl-8", - className + className, )} {...props} > @@ -51,7 +51,7 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={cn( "z-50 min-w-[8rem] overflow-hidden border bg-background p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> @@ -70,7 +70,7 @@ const DropdownMenuContent = React.forwardRef< className={cn( "z-50 min-w-[8rem] overflow-hidden border bg-background p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> @@ -90,7 +90,7 @@ const DropdownMenuItem = React.forwardRef< className={cn( "relative flex cursor-default select-none items-center px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", - className + className, )} {...(asDialogTrigger && { onSelect: (evt) => evt.preventDefault() })} {...props} @@ -105,18 +105,19 @@ const DropdownMenuCheckboxItem = React.forwardRef< - + {children} + + - {children} )); DropdownMenuCheckboxItem.displayName = @@ -130,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef< ref={ref} className={cn( "relative flex cursor-default select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className + className, )} {...props} > @@ -155,7 +156,7 @@ const DropdownMenuLabel = React.forwardRef< className={cn( "px-2 py-1.5 text-sm font-semibold", inset && "pl-8", - className + className, )} {...props} /> diff --git a/packages/ui/src/components/icons.tsx b/packages/ui/src/components/icons.tsx index b6d4a7b44e..b1b5f16127 100644 --- a/packages/ui/src/components/icons.tsx +++ b/packages/ui/src/components/icons.tsx @@ -33,14 +33,18 @@ import { MdKeyboardArrowUp, MdMenu, MdMoreHoriz, + MdOutlineAccountBalance, MdOutlineArrowDownward, MdOutlineArrowForward, + MdOutlineAttachFile, MdOutlineAutoAwesome, MdOutlineBackspace, MdOutlineBrokenImage, + MdOutlineCalendarMonth, MdOutlineCancel, MdOutlineCategory, MdOutlineChatBubbleOutline, + MdOutlineClear, MdOutlineContentCopy, MdOutlineDashboardCustomize, MdOutlineDelete, @@ -49,6 +53,7 @@ import { MdOutlineExitToApp, MdOutlineFace, MdOutlineFileDownload, + MdOutlineFilterList, MdOutlineForwardToInbox, MdOutlineHandyman, MdOutlineHourglassTop, @@ -66,6 +71,7 @@ import { MdOutlinePlayArrow, MdOutlineQuestionAnswer, MdOutlineSettings, + MdOutlineStyle, MdOutlineSubject, MdOutlineTask, MdOutlineTimer, @@ -546,6 +552,7 @@ export const Icons = { FolderImports: MdRuleFolder, FolderTransactions: MdTopic, Calendar: MdEditCalendar, + CalendarMonth: MdOutlineCalendarMonth, Reply: MdReplay, Sort: MdSort, Backspace: MdOutlineBackspace, @@ -555,4 +562,10 @@ export const Icons = { Menu: MdMenu, Mute: MdOutlineVolumeOff, UnMute: MdOutlineVolumeUp, + Clear: MdOutlineClear, + Filter: MdOutlineFilterList, + Status: MdOutlineStyle, + Attachments: MdOutlineAttachFile, + Accounts: MdOutlineAccountBalance, + Categories: MdOutlineCategory, }; diff --git a/packages/ui/src/components/month-range-picker.tsx b/packages/ui/src/components/month-range-picker.tsx deleted file mode 100644 index 911b3c7982..0000000000 --- a/packages/ui/src/components/month-range-picker.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { - endOfMonth, - formatISO, - isBefore, - isSameDay, - startOfMonth, - subMonths, -} from "date-fns"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; -import { cn } from "../utils"; -import { Button } from "./button"; - -const monthsNumber = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - -export type DateRange = { - from?: string | null; - to?: string | null; -}; - -type Props = React.HTMLAttributes & { - date?: DateRange; - setDate: (range?: DateRange) => void; -}; - -export const MonthRangePicker = ({ date, setDate }: Props) => { - const [yearOffset, setYearOffset] = useState(-1); - - const today = new Date(); - const fromDate = date?.from ? new Date(date.from) : null; - const toDate = date?.to ? new Date(date.to) : null; - - const isMonthSelected = (month: Date) => { - if (!fromDate || !toDate) { - return false; - } - - const startYearMonth = fromDate.getFullYear() * 12 + fromDate.getMonth(); - const endYearMonth = toDate.getFullYear() * 12 + toDate.getMonth(); - const currentYearMonth = month.getFullYear() * 12 + month.getMonth(); - - return ( - currentYearMonth >= startYearMonth && currentYearMonth <= endYearMonth - ); - }; - - const isMonthStart = (month: Date) => { - if (!fromDate) { - return false; - } - - const startYearMonth = fromDate.getFullYear() * 12 + fromDate.getMonth(); - const currentYearMonth = month.getFullYear() * 12 + month.getMonth(); - - return currentYearMonth === startYearMonth; - }; - - const isMonthEnd = (month: Date) => { - if (!toDate) { - return false; - } - - const endYearMonth = toDate.getFullYear() * 12 + toDate.getMonth(); - const currentYearMonth = month.getFullYear() * 12 + month.getMonth(); - - return currentYearMonth === endYearMonth; - }; - - const handleMonthClick = (selectedDate: Date) => { - if (toDate && isSameDay(endOfMonth(selectedDate), toDate)) { - setDate({ - from: null, - to: null, - }); - - return; - } - - if (!date?.from || date?.to) { - setDate({ - from: formatISO(startOfMonth(new Date(selectedDate)), { - representation: "date", - }), - to: null, - }); - } else if (fromDate && selectedDate < fromDate) { - setDate({ - from: formatISO(startOfMonth(new Date(selectedDate)), { - representation: "date", - }), - to: date?.from - ? formatISO(endOfMonth(new Date(date.from)), { - representation: "date", - }) - : null, - }); - } else { - setDate({ - to: formatISO(endOfMonth(new Date(selectedDate)), { - representation: "date", - }), - }); - } - }; - - const renderMonth = (year: number, month: number) => { - const monthStart = new Date(year, month, 1); - const isSelected = isMonthSelected(monthStart); - const isStart = isMonthStart(monthStart); - const isEnd = isMonthEnd(monthStart); - - const isSelectedDate = isStart || isEnd; - const isRange = isSelected && !isSelectedDate; - const isDisabled = isBefore(today, subMonths(endOfMonth(monthStart), 1)); - - return ( - - ); - }; - - const renderYear = (year: number) => - monthsNumber.map((month) => renderMonth(year, month)); - - return ( - <> -
- - - -
- -
-

{today.getFullYear() + yearOffset}

-

- {today.getFullYear() + yearOffset + 1} -

-
- -
-
- {renderYear(today.getFullYear() + yearOffset)} -
-
- {renderYear(today.getFullYear() + yearOffset + 1)} -
-
- - ); -}; diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 1a50935f43..60e11fab8e 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -251,4 +251,13 @@ to { width: 1.25em; } +} + +/* Date picker */ +.rdp-tbody .rdp-button { + border-radius: 100%; +} + +.aria-selected\:text-accent-foreground[aria-selected="true"] { + border-radius: 0px; } \ No newline at end of file diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts new file mode 100644 index 0000000000..ac3d0391f7 --- /dev/null +++ b/packages/ui/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./use-media-query"; +export * from "./use-resize-observer"; +export * from "./use-enter-submit"; diff --git a/apps/website/src/hooks/use-enter-submit.ts b/packages/ui/src/hooks/use-enter-submit.ts similarity index 90% rename from apps/website/src/hooks/use-enter-submit.ts rename to packages/ui/src/hooks/use-enter-submit.ts index 3be2fbf53a..d19cbe4625 100644 --- a/apps/website/src/hooks/use-enter-submit.ts +++ b/packages/ui/src/hooks/use-enter-submit.ts @@ -7,7 +7,7 @@ export function useEnterSubmit(): { const formRef = useRef(null); const handleKeyDown = ( - event: React.KeyboardEvent + event: React.KeyboardEvent, ): void => { if ( event.key === "Enter" && diff --git a/apps/dashboard/src/hooks/use-media-query.ts b/packages/ui/src/hooks/use-media-query.ts similarity index 100% rename from apps/dashboard/src/hooks/use-media-query.ts rename to packages/ui/src/hooks/use-media-query.ts diff --git a/packages/ui/src/hooks/use-resize-observer.ts b/packages/ui/src/hooks/use-resize-observer.ts new file mode 100644 index 0000000000..28d34c9092 --- /dev/null +++ b/packages/ui/src/hooks/use-resize-observer.ts @@ -0,0 +1,24 @@ +import { type RefObject, useEffect, useState } from "react"; + +export function useResizeObserver( + elementRef: RefObject, +): ResizeObserverEntry | undefined { + const [entry, setEntry] = useState(); + + const updateEntry = ([entry]: ResizeObserverEntry[]): void => { + setEntry(entry); + }; + + useEffect(() => { + const node = elementRef?.current; + if (!node) return; + + const observer = new ResizeObserver(updateEntry); + + observer.observe(node); + + return () => observer.disconnect(); + }, [elementRef]); + + return entry; +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 963c6b2f4b..850bfbf263 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1 +1,2 @@ export * from "./cn"; +export * from "./truncate"; diff --git a/packages/ui/src/utils/truncate.ts b/packages/ui/src/utils/truncate.ts new file mode 100644 index 0000000000..071d5956e4 --- /dev/null +++ b/packages/ui/src/utils/truncate.ts @@ -0,0 +1,7 @@ +export const truncate = ( + str: string | null | undefined, + length: number, +): string | null => { + if (!str || str.length <= length) return str ?? null; + return `${str.slice(0, length - 3)}...`; +};