diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/expense-form.tsx | 388 | ||||
| -rw-r--r-- | src/components/ui/dialog.tsx | 122 |
2 files changed, 193 insertions, 317 deletions
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index 0f677e6..bdee9d0 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -1,12 +1,14 @@ 'use client' -import { - createExpenseAction, - deleteExpenseAction, - updateExpenseAction, -} from '@/app/groups/[groupId]/expenses/actions' import { AsyncButton } from '@/components/async-button' import { SubmitButton } from '@/components/submit-button' import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Form, @@ -28,15 +30,17 @@ import { import { getExpense, getGroup } from '@/lib/api' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { zodResolver } from '@hookform/resolvers/zod' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { useForm } from 'react-hook-form' export type Props = { group: NonNullable<Awaited<ReturnType<typeof getGroup>>> expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>> + onSubmit: (values: ExpenseFormValues) => Promise<void> + onDelete?: () => Promise<void> } -export function ExpenseForm({ group, expense }: Props) { +export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { const isCreate = expense === undefined const searchParams = useSearchParams() const form = useForm<ExpenseFormValues>({ @@ -61,210 +65,204 @@ export function ExpenseForm({ group, expense }: Props) { } : { title: '', amount: 0, paidFor: [], isReimbursement: false }, }) - const router = useRouter() return ( <Form {...form}> - <form - onSubmit={form.handleSubmit(async (values) => { - if (expense) { - await updateExpenseAction(group.id, expense.id, values) - } else { - await createExpenseAction(group.id, values) - } - router.back() - })} - > - <div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem className="order-1"> - <FormLabel>Expense title</FormLabel> - <FormControl> - <Input - placeholder="Monday evening restaurant" - className="text-base" - {...field} - /> - </FormControl> - <FormDescription> - Enter a description for the expense. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="paidBy" - render={({ field }) => ( - <FormItem className="order-3 sm:order-2"> - <FormLabel>Paid by</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <SelectTrigger> - <SelectValue placeholder="Select a participant" /> - </SelectTrigger> - <SelectContent> - {group.participants.map(({ id, name }) => ( - <SelectItem key={id} value={id}> - {name} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormDescription> - Select the participant who paid the expense. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="amount" - render={({ field }) => ( - <FormItem className="order-2 sm:order-3"> - <FormLabel>Amount</FormLabel> - <div className="flex items-baseline gap-2"> - <span>{group.currency}</span> + <form onSubmit={form.handleSubmit((values) => onSubmit(values))}> + <Card> + <CardHeader> + <CardTitle> + {isCreate ? <>Create expense</> : <>Edit expense</>} + </CardTitle> + </CardHeader> + <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6"> + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem className="order-1"> + <FormLabel>Expense title</FormLabel> <FormControl> <Input - className="text-base max-w-[120px]" - type="number" - inputMode="decimal" - step={0.01} - placeholder="0.00" + placeholder="Monday evening restaurant" + className="text-base" {...field} /> </FormControl> - </div> - <FormMessage /> - - <FormField - control={form.control} - name="isReimbursement" - render={({ field }) => ( - <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div> - <FormLabel>This is a reimbursement</FormLabel> - </div> - </FormItem> - )} - /> - </FormItem> - )} - /> + <FormDescription> + Enter a description for the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> - <FormField - control={form.control} - name="paidFor" - render={() => ( - <FormItem className="order-5"> - <div className="mb-4"> - <FormLabel> - Paid for - <Button - variant="link" - type="button" - className="-m-2" - onClick={() => { - const paidFor = form.getValues().paidFor - const allSelected = - paidFor.length === group.participants.length - const newPairFor = allSelected - ? [] - : group.participants.map((p) => p.id) - form.setValue('paidFor', newPairFor, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - }} - > - {form.getValues().paidFor.length === - group.participants.length ? ( - <>Select none</> - ) : ( - <>Select all</> - )} - </Button> - </FormLabel> + <FormField + control={form.control} + name="paidBy" + render={({ field }) => ( + <FormItem className="order-3 sm:order-2"> + <FormLabel>Paid by</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a participant" /> + </SelectTrigger> + <SelectContent> + {group.participants.map(({ id, name }) => ( + <SelectItem key={id} value={id}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> <FormDescription> - Select who the expense was paid for. + Select the participant who paid the expense. </FormDescription> - </div> - {group.participants.map(({ id, name }) => ( + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="amount" + render={({ field }) => ( + <FormItem className="order-2 sm:order-3"> + <FormLabel>Amount</FormLabel> + <div className="flex items-baseline gap-2"> + <span>{group.currency}</span> + <FormControl> + <Input + className="text-base max-w-[120px]" + type="number" + inputMode="decimal" + step={0.01} + placeholder="0.00" + {...field} + /> + </FormControl> + </div> + <FormMessage /> + <FormField - key={id} control={form.control} - name="paidFor" - render={({ field }) => { - return ( - <FormItem - key={id} - className="flex flex-row items-start space-x-3 space-y-0" - > - <FormControl> - <Checkbox - checked={field.value?.includes(id)} - onCheckedChange={(checked) => { - return checked - ? field.onChange([...field.value, id]) - : field.onChange( - field.value?.filter( - (value) => value !== id, - ), - ) - }} - /> - </FormControl> - <FormLabel className="text-sm font-normal"> - {name} - </FormLabel> - </FormItem> - ) - }} + name="isReimbursement" + render={({ field }) => ( + <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div> + <FormLabel>This is a reimbursement</FormLabel> + </div> + </FormItem> + )} /> - ))} - <FormMessage /> - </FormItem> - )} - /> - </div> + </FormItem> + )} + /> - <div className="mt-6 flex gap-2"> - <SubmitButton - loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} - > - {isCreate ? <>Create</> : <>Save</>} - </SubmitButton> - {!isCreate && ( - <AsyncButton - type="button" - variant="destructive" - loadingContent="Deleting…" - action={async () => { - await deleteExpenseAction(group.id, expense.id) - router.back() - }} + <FormField + control={form.control} + name="paidFor" + render={() => ( + <FormItem className="order-5"> + <div className="mb-4"> + <FormLabel> + Paid for + <Button + variant="link" + type="button" + className="-m-2" + onClick={() => { + const paidFor = form.getValues().paidFor + const allSelected = + paidFor.length === group.participants.length + const newPairFor = allSelected + ? [] + : group.participants.map((p) => p.id) + form.setValue('paidFor', newPairFor, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }} + > + {form.getValues().paidFor.length === + group.participants.length ? ( + <>Select none</> + ) : ( + <>Select all</> + )} + </Button> + </FormLabel> + <FormDescription> + Select who the expense was paid for. + </FormDescription> + </div> + {group.participants.map(({ id, name }) => ( + <FormField + key={id} + control={form.control} + name="paidFor" + render={({ field }) => { + return ( + <FormItem + key={id} + className="flex flex-row items-start space-x-3 space-y-0" + > + <FormControl> + <Checkbox + checked={field.value?.includes(id)} + onCheckedChange={(checked) => { + return checked + ? field.onChange([...field.value, id]) + : field.onChange( + field.value?.filter( + (value) => value !== id, + ), + ) + }} + /> + </FormControl> + <FormLabel className="text-sm font-normal"> + {name} + </FormLabel> + </FormItem> + ) + }} + /> + ))} + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + + <CardFooter className="gap-2"> + <SubmitButton + loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} > - Delete - </AsyncButton> - )} - </div> + {isCreate ? <>Create</> : <>Save</>} + </SubmitButton> + {!isCreate && onDelete && ( + <AsyncButton + type="button" + variant="destructive" + loadingContent="Deleting…" + action={onDelete} + > + Delete + </AsyncButton> + )} + </CardFooter> + </Card> </form> </Form> ) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx deleted file mode 100644 index cad6f58..0000000 --- a/src/components/ui/dialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Dialog = DialogPrimitive.Root - -const DialogTrigger = DialogPrimitive.Trigger - -const DialogPortal = DialogPrimitive.Portal - -const DialogClose = DialogPrimitive.Close - -const DialogOverlay = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Overlay - ref={ref} - className={cn( - "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className - )} - {...props} - /> -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> ->(({ className, children, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className - )} - {...props} - > - {children} - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="h-4 w-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-1.5 text-center sm:text-left", - className - )} - {...props} - /> -) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className - )} - {...props} - /> -) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className - )} - {...props} - /> -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} |
