Release v1.1.0 (#40)

This commit is contained in:
Markus Thielker 2024-03-17 01:31:06 +01:00 committed by GitHub
commit b34f5e7f3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 560 additions and 308 deletions

17
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "next-finances",
"version": "1.0.1",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-finances",
"version": "1.0.1",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@hookform/resolvers": "^3.3.4",
@ -40,6 +40,7 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"zod": "^3.22.4"
},
"devDependencies": {
@ -6184,6 +6185,18 @@
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"devOptional": true
},
"node_modules/vaul": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz",
"integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -2,7 +2,7 @@
"name": "next-finances",
"description": "A finances application to keep track of my personal spendings",
"homepage": "https://github.com/MarkusThielker/next-finances",
"version": "1.0.1",
"version": "1.1.0",
"license": "MIT",
"author": {
"name": "Markus Thielker"
@ -50,6 +50,7 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"zod": "^3.22.4"
},
"devDependencies": {

View file

@ -42,7 +42,7 @@ export default async function AccountPage() {
return (
<div className="flex flex-col items-center">
<Card className="w-full max-w-md mt-12">
<Card className="w-full max-w-md md:mt-12">
<CardHeader>
<CardTitle>Hey, {user?.username}!</CardTitle>
<CardDescription>This is your account overview.</CardDescription>
@ -81,7 +81,7 @@ export default async function AccountPage() {
</div>
</div>
</CardContent>
<CardFooter className="space-x-4">
<CardFooter className="grid gap-4 grid-cols-1 md:grid-cols-2">
{
process.env.NODE_ENV === 'development' && (
<GenerateSampleDataForm onSubmit={generateSampleData}/>

View file

@ -25,6 +25,7 @@ export const columns = (
</svg>
);
},
size: 65,
},
{
accessorKey: 'createdAt',

View file

@ -25,6 +25,6 @@ export default async function CategoriesPage() {
categories={categories}
onSubmit={categoryCreateUpdate}
onDelete={categoryDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

View file

@ -17,6 +17,7 @@ export const columns = (
{
accessorKey: 'type',
header: 'Type',
size: 100,
},
{
accessorKey: 'createdAt',

View file

@ -28,6 +28,6 @@ export default async function EntitiesPage() {
entities={entities}
onSubmit={entityCreateUpdate}
onDelete={entityDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

View file

@ -22,10 +22,10 @@ export default function RootLayout({
<html lang="en">
<body className={cn('dark', inter.className)}>
<Navigation/>
<main>
<main className="p-4 sm:p-8">
{children}
<Toaster/>
</main>
<Toaster/>
</body>
</html>
);

View file

@ -200,7 +200,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
categoryPercentages={categoryPercentages}
entityExpenses={entityExpensesFormat}
entityPercentages={entityPercentages}
className="flex flex-col justify-center space-y-4 p-10"
className="flex flex-col justify-center space-y-4"
/>
);
}

View file

@ -18,6 +18,7 @@ export const columns = (
cell: ({row}) => {
return format(row.original.date, 'PPP');
},
size: 175,
},
{
accessorKey: 'amount',
@ -28,6 +29,7 @@ export const columns = (
currency: 'EUR',
}).format(row.getValue('amount') as number / 100);
},
size: 70,
},
{
accessorKey: 'payorId',
@ -36,6 +38,7 @@ export const columns = (
const entity = entities.find((entity) => entity.id === row.original.payorId);
return entity?.name ?? '-';
},
size: 200,
},
{
accessorKey: 'payeeId',
@ -44,6 +47,7 @@ export const columns = (
const entity = entities.find((entity) => entity.id === row.original.payeeId);
return entity?.name ?? '-';
},
size: 200,
},
{
accessorKey: 'categoryId',
@ -60,10 +64,12 @@ export const columns = (
</div>
);
},
size: 200,
},
{
accessorKey: 'note',
header: 'Note',
size: 200,
},
{
id: 'actions',

View file

@ -58,6 +58,6 @@ export default async function PaymentsPage() {
categories={categories}
onSubmit={paymentCreateUpdate}
onDelete={paymentDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

View file

@ -23,6 +23,8 @@ import {
} from '@/components/ui/alert-dialog';
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
import CategoryForm from '@/components/form/categoryForm';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
categories: Category[],
@ -31,6 +33,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -97,26 +100,51 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
<p className="text-3xl font-semibold">Categories</p>
{/* Edit dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedCategory(undefined);
setIsEditDialogOpen(true);
}}>
Create Category
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
</DialogHeader>
<CategoryForm
value={selectedCategory}
onSubmit={handleSubmit}
className="flex flex-row space-x-4 py-4"/>
</DialogContent>
</Dialog>
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedCategory(undefined);
setIsEditDialogOpen(true);
}}>
Create Category
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
</DialogHeader>
<CategoryForm
value={selectedCategory}
onSubmit={handleSubmit}
className="flex flex-row space-x-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedCategory(undefined);
setIsEditDialogOpen(true);
}}>
Create Category
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DrawerTitle>
</DrawerHeader>
<CategoryForm
value={selectedCategory}
onSubmit={handleSubmit}
className="flex flex-row space-x-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Data Table */}

View file

@ -49,7 +49,7 @@ export default function DashboardPageClientContent(
return (
<div className={className}>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full space-x-8">
<p className="text-3xl font-semibold">Dashboard</p>

View file

@ -24,6 +24,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
} from '@/components/ui/alert-dialog';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
export default function EntityPageClientContent({entities, onSubmit, onDelete, className}: {
entities: Entity[],
@ -32,6 +34,7 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -125,26 +128,51 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
<p className="text-3xl font-semibold">Entities</p>
{/* Edit dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedEntity(undefined);
setIsEditDialogOpen(true);
}}>
Create Entity
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
</DialogHeader>
<EntityForm
value={selectedEntity}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedEntity(undefined);
setIsEditDialogOpen(true);
}}>
Create Entity
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
</DialogHeader>
<EntityForm
value={selectedEntity}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedEntity(undefined);
setIsEditDialogOpen(true);
}}>
Create Entity
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DrawerTitle>
</DrawerHeader>
<EntityForm
value={selectedEntity}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Filter input */}

View file

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React, { useState } from 'react';
import React, { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import CurrencyInput from '@/components/ui/currency-input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
value: Payment | undefined,
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
const router = useRouter();
const [filter, setFilter] = useState<string>('');
const [payorOpen, setPayorOpen] = useState(false);
const [payeeOpen, setPayeeOpen] = useState(false);
const [categoryOpen, setCategoryOpen] = useState(false);
const form = useForm<z.infer<typeof paymentFormSchema>>({
resolver: zodResolver(paymentFormSchema),
defaultValues: {
@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
};
}) ?? [];
const payeeRef = useRef<HTMLInputElement>(null);
const categoryRef = useRef<HTMLInputElement>(null);
const noteRef = useRef<HTMLInputElement>(null);
return (
<Form {...form}>
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
@ -145,61 +143,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payor</FormLabel>
<Popover open={payorOpen} onOpenChange={(open) => {
setPayorOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-64">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayorOpen(false);
}}>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl>
<AutoCompleteInput
placeholder="Select payor"
items={entitiesMapped}
next={payeeRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
@ -211,62 +161,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payee</FormLabel>
<Popover open={payeeOpen} onOpenChange={(open) => {
setPayeeOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayeeOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl ref={payeeRef}>
<AutoCompleteInput
placeholder="Select payee"
items={entitiesMapped}
next={categoryRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
@ -278,62 +179,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Popover open={categoryOpen} onOpenChange={(open) => {
setCategoryOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? categoriesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{categoriesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setCategoryOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl ref={categoryRef}>
<AutoCompleteInput
placeholder="Select category"
items={categoriesMapped}
next={noteRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}

View file

@ -2,65 +2,125 @@
import {
NavigationMenu,
navigationMenuIconTriggerStyle,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import React from 'react';
import React, { useState } from 'react';
import Link from 'next/link';
import { User } from 'lucide-react';
import { Banknote, Home, Menu, Tag, User, UserSearch } from 'lucide-react';
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
export default function Navigation() {
const [open, setOpen] = useState(false);
return (
<div className="flex sticky items-center border-b border-border bg-background">
<NavigationMenu>
<NavigationMenuList className="flex w-screen items-center justify-between px-4 py-2">
<div className="inline-flex space-x-2">
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
<NavigationMenuItem>
<Link href="/" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Dashboard
</NavigationMenuLink>
<div className="md:hidden">
<Drawer open={open} onOpenChange={open => setOpen(open)}>
<DrawerTrigger asChild>
<Button size="icon" variant="ghost" className="m-2">
<Menu/>
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="flex flex-col space-y-2 w-full rounded-none p-4">
<Link
href="/"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Home/>
<span>Dashboard</span>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/payments" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Payments
</NavigationMenuLink>
<Link
href="/payments"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Banknote/>
<span>Payments</span>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/entities" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Entities
</NavigationMenuLink>
<Link
href="/entities"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<UserSearch/>
<span>Entities</span>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/categories" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Categories
</NavigationMenuLink>
<Link
href="/categories"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Tag/>
<span>Categories</span>
</Link>
</NavigationMenuItem>
</div>
<NavigationMenuItem>
<Link href="/account" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
<span className="sr-only">Account</span>
<Link
href="/account"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<User/>
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<span>Account</span>
</Link>
</div>
</DrawerContent>
</Drawer>
</div>
<div className="hidden md:flex">
<NavigationMenu>
<NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2">
<div className="inline-flex space-x-2">
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
<NavigationMenuItem>
<Link href="/" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Dashboard
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/payments" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Payments
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/entities" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Entities
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/categories" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Categories
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</div>
<NavigationMenuItem>
<Link href="/account" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
<span className="sr-only">Account</span>
<User/>
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
);
}

View file

@ -23,6 +23,8 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import { Category, Entity, Payment } from '@prisma/client';
import PaymentForm from '@/components/form/paymentForm';
import { columns } from '@/app/payments/columns';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
export default function PaymentPageClientContent({
payments,
@ -40,6 +42,7 @@ export default function PaymentPageClientContent({
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -100,48 +103,61 @@ export default function PaymentPageClientContent({
);
};
const entitiesMapped = entities?.map((entity) => {
return {
label: entity.name,
value: entity.id,
};
}) ?? [];
const categoriesMapped = categories?.map((category) => {
return {
label: category.name,
value: category.id,
};
}) ?? [];
return (
<div className={className}>
<div className="flex items-center justify-between w-full">
<p className="text-3xl font-semibold">Payments</p>
{/* Edit dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedPayment(undefined);
setIsEditDialogOpen(true);
}}>
Create Payment
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
</DialogHeader>
<PaymentForm
value={selectedPayment}
entities={entities}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedPayment(undefined);
setIsEditDialogOpen(true);
}}>
Create Payment
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
</DialogHeader>
<PaymentForm
value={selectedPayment}
entities={entities}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedPayment(undefined);
setIsEditDialogOpen(true);
}}>
Create Payment
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DrawerTitle>
</DrawerHeader>
<PaymentForm
value={selectedPayment}
entities={entities}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Data Table */}

View file

@ -0,0 +1,100 @@
'use client';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
export interface AutoCompleteInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
items: { label: string, value: any }[];
next?: React.RefObject<HTMLInputElement>;
}
const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>(
({className, type, ...props}, ref) => {
const [value, setValue] = useState(getInitialValue());
const [open, setOpen] = useState(false);
const [lastKey, setLastKey] = useState('');
const [filteredItems, setFilteredItems] = useState(props.items);
function getInitialValue() {
if (!props.items) {
return '';
}
const item = props.items?.find(item => item.value === props.value);
return item?.label || '';
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
props.onChange?.(undefined as any);
const value = e.target.value;
setFilteredItems(props?.items?.filter((item) => {
return item.label.toLowerCase().includes(value.toLowerCase());
}));
setValue(value);
setOpen(value.length > 0);
}
useEffect(() => {
if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) {
setValue(filteredItems[0].label);
setOpen(false);
props.onChange?.({target: {value: filteredItems[0].value}} as any);
props.next && props.next.current?.focus();
}
}, [filteredItems]);
return (
<div className="relative">
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
value={value}
placeholder={props.placeholder || 'Search...'}
onChange={handleChange}
onKeyDown={(e) => {
if (e.metaKey || e.ctrlKey || e.altKey) {
props.onKeyDown?.(e);
return;
}
setLastKey(e.key);
props.onKeyDown?.(e);
}}
/>
{
open && (
<div
className="z-50 bg-background rounded-md border border-border absolute inset-x-0 top-12 max-h-44 overflow-scroll">
{filteredItems?.map((item) =>
<div
className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium"
onClick={() => {
props.onChange?.({target: {value: item.value}} as any);
props.next && props.next.current?.focus();
setValue(item.label);
setOpen(false);
}}
key={item.value}>
{item.label}
</div>,
)}
</div>
)
}
</div>
);
},
);
AutoCompleteInput.displayName = 'Input';
export { AutoCompleteInput };

View file

@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
<Input
placeholder={props.placeholder}
type="text"
inputMode="numeric"
{...field}
onChange={(ev) => {
setValue(ev.target.value);

View file

@ -45,7 +45,8 @@ export function DataTable<TData, TValue>({
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
<TableHead key={header.id}
style={{minWidth: `${header.column.getSize()}px`}}>
{header.isPlaceholder
? null
: flexRender(

View file

@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({className, children, ...props}, ref) => (
<DrawerPortal>
<DrawerOverlay/>
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted"/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View file

@ -44,6 +44,10 @@ const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
);
const navigationMenuIconTriggerStyle = cva(
'group inline-flex h-10 w-full items-center justify-start rounded-md bg-background px-4 py-2 space-x-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName =
export {
navigationMenuTriggerStyle,
navigationMenuIconTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,

View file

@ -0,0 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(mq: string) {
const [matches, setMatch] = useState(
() => typeof window !== 'undefined' ? window.matchMedia(mq).matches : false,
);
useEffect(() => {
if (typeof window !== 'undefined') {
const mql = window.matchMedia(mq);
const listener = (e: any) => setMatch(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}
}, [mq]);
return matches;
}