From 0a8e56f80033ce889b38b01f2287cd6fbbe36dd6 Mon Sep 17 00:00:00 2001
From: Sebastien Castiel <sebastien@castiel.me>
Date: Mon, 8 Jan 2024 18:11:11 +0100
Subject: Add splitmode and shares to expenses (#11)

* Add splitmode and shares to expenses

* Update balances based on shares

* Change field size

* Form validation

* Redesign expense form

* Split unevenly by amount
---
 src/components/expense-form.tsx | 390 +++++++++++++++++++++++++++++-----------
 1 file changed, 285 insertions(+), 105 deletions(-)

(limited to 'src/components/expense-form.tsx')

diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index bdee9d0..af3a923 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -5,11 +5,16 @@ import { Button } from '@/components/ui/button'
 import {
   Card,
   CardContent,
-  CardFooter,
+  CardDescription,
   CardHeader,
   CardTitle,
 } from '@/components/ui/card'
 import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from '@/components/ui/collapsible'
 import {
   Form,
   FormControl,
@@ -29,9 +34,12 @@ import {
 } from '@/components/ui/select'
 import { getExpense, getGroup } from '@/lib/api'
 import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
+import { cn } from '@/lib/utils'
 import { zodResolver } from '@hookform/resolvers/zod'
+import { Save, Trash2 } from 'lucide-react'
 import { useSearchParams } from 'next/navigation'
 import { useForm } from 'react-hook-form'
+import { match } from 'ts-pattern'
 
 export type Props = {
   group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -50,7 +58,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
           title: expense.title,
           amount: String(expense.amount / 100) as unknown as number, // hack
           paidBy: expense.paidById,
-          paidFor: expense.paidFor.map(({ participantId }) => participantId),
+          paidFor: expense.paidFor.map(({ participantId, shares }) => ({
+            participant: participantId,
+            shares: String(shares / 100) as unknown as number,
+          })),
+          splitMode: expense.splitMode,
           isReimbursement: expense.isReimbursement,
         }
       : searchParams.get('reimbursement')
@@ -60,10 +72,20 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
             (Number(searchParams.get('amount')) || 0) / 100,
           ) as unknown as number, // hack
           paidBy: searchParams.get('from') ?? undefined,
-          paidFor: [searchParams.get('to') ?? undefined],
+          paidFor: [
+            searchParams.get('to')
+              ? { participant: searchParams.get('to')! }
+              : undefined,
+          ],
           isReimbursement: true,
         }
-      : { title: '', amount: 0, paidFor: [], isReimbursement: false },
+      : {
+          title: '',
+          amount: 0,
+          paidFor: [],
+          isReimbursement: false,
+          splitMode: 'EVENLY',
+        },
   })
 
   return (
@@ -75,12 +97,12 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
               {isCreate ? <>Create expense</> : <>Edit expense</>}
             </CardTitle>
           </CardHeader>
-          <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6">
+          <CardContent className="grid sm:grid-cols-2 gap-6">
             <FormField
               control={form.control}
               name="title"
               render={({ field }) => (
-                <FormItem className="order-1">
+                <FormItem className="">
                   <FormLabel>Expense title</FormLabel>
                   <FormControl>
                     <Input
@@ -97,40 +119,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
               )}
             />
 
-            <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">
+                <FormItem className="sm:order-3">
                   <FormLabel>Amount</FormLabel>
                   <div className="flex items-baseline gap-2">
                     <span>{group.currency}</span>
@@ -168,44 +161,82 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
               )}
             />
 
+            <FormField
+              control={form.control}
+              name="paidBy"
+              render={({ field }) => (
+                <FormItem className="sm:order-5">
+                  <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>
+              )}
+            />
+          </CardContent>
+        </Card>
+
+        <Card className="mt-4">
+          <CardHeader>
+            <CardTitle className="flex justify-between">
+              <span>Paid for</span>
+              <Button
+                variant="link"
+                type="button"
+                className="-my-2 -mx-4"
+                onClick={() => {
+                  const paidFor = form.getValues().paidFor
+                  const allSelected =
+                    paidFor.length === group.participants.length
+                  const newPaidFor = allSelected
+                    ? []
+                    : group.participants.map((p) => ({
+                        participant: p.id,
+                        shares:
+                          paidFor.find((pfor) => pfor.participant === p.id)
+                            ?.shares ?? ('1' as unknown as number),
+                      }))
+                  form.setValue('paidFor', newPaidFor, {
+                    shouldDirty: true,
+                    shouldTouch: true,
+                    shouldValidate: true,
+                  })
+                }}
+              >
+                {form.getValues().paidFor.length ===
+                group.participants.length ? (
+                  <>Select none</>
+                ) : (
+                  <>Select all</>
+                )}
+              </Button>
+            </CardTitle>
+            <CardDescription>
+              Select who the expense was paid for.
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
             <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>
+                <FormItem className="sm:order-4 row-span-2 space-y-0">
                   {group.participants.map(({ id, name }) => (
                     <FormField
                       key={id}
@@ -213,28 +244,123 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
                       name="paidFor"
                       render={({ field }) => {
                         return (
-                          <FormItem
-                            key={id}
-                            className="flex flex-row items-start space-x-3 space-y-0"
+                          <div
+                            data-id={`${id}/${form.getValues().splitMode}/${
+                              group.currency
+                            }`}
+                            className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
                           >
-                            <FormControl>
-                              <Checkbox
-                                checked={field.value?.includes(id)}
-                                onCheckedChange={(checked) => {
-                                  return checked
-                                    ? field.onChange([...field.value, id])
-                                    : field.onChange(
-                                        field.value?.filter(
-                                          (value) => value !== id,
+                            <FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
+                              <FormControl>
+                                <Checkbox
+                                  checked={field.value?.some(
+                                    ({ participant }) => participant === id,
+                                  )}
+                                  onCheckedChange={(checked) => {
+                                    return checked
+                                      ? field.onChange([
+                                          ...field.value,
+                                          {
+                                            participant: id,
+                                            shares: '1',
+                                          },
+                                        ])
+                                      : field.onChange(
+                                          field.value?.filter(
+                                            (value) => value.participant !== id,
+                                          ),
+                                        )
+                                  }}
+                                />
+                              </FormControl>
+                              <FormLabel className="text-sm font-normal flex-1">
+                                {name}
+                              </FormLabel>
+                            </FormItem>
+                            {form.getValues().splitMode !== 'EVENLY' && (
+                              <FormField
+                                name={`paidFor[${field.value.findIndex(
+                                  ({ participant }) => participant === id,
+                                )}].shares`}
+                                render={() => {
+                                  const sharesLabel = (
+                                    <span
+                                      className={cn('text-sm', {
+                                        'text-muted': !field.value?.some(
+                                          ({ participant }) =>
+                                            participant === id,
                                         ),
-                                      )
+                                      })}
+                                    >
+                                      {match(form.getValues().splitMode)
+                                        .with('BY_SHARES', () => <>share(s)</>)
+                                        .with('BY_PERCENTAGE', () => <>%</>)
+                                        .with('BY_AMOUNT', () => (
+                                          <>{group.currency}</>
+                                        ))
+                                        .otherwise(() => (
+                                          <></>
+                                        ))}
+                                    </span>
+                                  )
+                                  return (
+                                    <div>
+                                      <div className="flex gap-1 items-baseline">
+                                        {form.getValues().splitMode ===
+                                          'BY_AMOUNT' && sharesLabel}
+                                        <FormControl>
+                                          <Input
+                                            key={String(
+                                              !field.value?.some(
+                                                ({ participant }) =>
+                                                  participant === id,
+                                              ),
+                                            )}
+                                            className="text-base w-[80px] -my-2"
+                                            type="number"
+                                            disabled={
+                                              !field.value?.some(
+                                                ({ participant }) =>
+                                                  participant === id,
+                                              )
+                                            }
+                                            value={
+                                              field.value?.find(
+                                                ({ participant }) =>
+                                                  participant === id,
+                                              )?.shares
+                                            }
+                                            onChange={(event) =>
+                                              field.onChange(
+                                                field.value.map((p) =>
+                                                  p.participant === id
+                                                    ? {
+                                                        participant: id,
+                                                        shares:
+                                                          event.target.value,
+                                                      }
+                                                    : p,
+                                                ),
+                                              )
+                                            }
+                                            inputMode="numeric"
+                                            step={1}
+                                          />
+                                        </FormControl>
+                                        {[
+                                          'BY_SHARES',
+                                          'BY_PERCENTAGE',
+                                        ].includes(
+                                          form.getValues().splitMode,
+                                        ) && sharesLabel}
+                                      </div>
+                                      <FormMessage className="float-right" />
+                                    </div>
+                                  )
                                 }}
                               />
-                            </FormControl>
-                            <FormLabel className="text-sm font-normal">
-                              {name}
-                            </FormLabel>
-                          </FormItem>
+                            )}
+                          </div>
                         )
                       }}
                     />
@@ -243,26 +369,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
                 </FormItem>
               )}
             />
+
+            <Collapsible className="mt-5">
+              <CollapsibleTrigger asChild>
+                <Button variant="link" className="-mx-4">
+                  Advanced splitting options…
+                </Button>
+              </CollapsibleTrigger>
+              <CollapsibleContent>
+                <div className="grid sm:grid-cols-2 gap-6 pt-3">
+                  <FormField
+                    control={form.control}
+                    name="splitMode"
+                    render={({ field }) => (
+                      <FormItem className="sm:order-2">
+                        <FormLabel>Split mode</FormLabel>
+                        <FormControl>
+                          <Select
+                            onValueChange={(value) => {
+                              form.setValue('splitMode', value as any, {
+                                shouldDirty: true,
+                                shouldTouch: true,
+                                shouldValidate: true,
+                              })
+                            }}
+                            defaultValue={field.value}
+                          >
+                            <SelectTrigger>
+                              <SelectValue />
+                            </SelectTrigger>
+                            <SelectContent>
+                              <SelectItem value="EVENLY">Evenly</SelectItem>
+                              <SelectItem value="BY_SHARES">
+                                Unevenly – By shares
+                              </SelectItem>
+                              <SelectItem value="BY_PERCENTAGE">
+                                Unevenly – By percentage
+                              </SelectItem>
+                              <SelectItem value="BY_AMOUNT">
+                                Unevenly – By amount
+                              </SelectItem>
+                            </SelectContent>
+                          </Select>
+                        </FormControl>
+                        <FormDescription>
+                          Select how to split the expense.
+                        </FormDescription>
+                      </FormItem>
+                    )}
+                  />
+                </div>
+              </CollapsibleContent>
+            </Collapsible>
           </CardContent>
+        </Card>
 
-          <CardFooter className="gap-2">
-            <SubmitButton
-              loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
+        <div className="flex mt-4 gap-2">
+          <SubmitButton
+            loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
+          >
+            <Save className="w-4 h-4 mr-2" />
+            {isCreate ? <>Create</> : <>Save</>}
+          </SubmitButton>
+          {!isCreate && onDelete && (
+            <AsyncButton
+              type="button"
+              variant="destructive"
+              loadingContent="Deleting…"
+              action={onDelete}
             >
-              {isCreate ? <>Create</> : <>Save</>}
-            </SubmitButton>
-            {!isCreate && onDelete && (
-              <AsyncButton
-                type="button"
-                variant="destructive"
-                loadingContent="Deleting…"
-                action={onDelete}
-              >
-                Delete
-              </AsyncButton>
-            )}
-          </CardFooter>
-        </Card>
+              <Trash2 className="w-4 h-4 mr-2" />
+              Delete
+            </AsyncButton>
+          )}
+        </div>
       </form>
     </Form>
   )
-- 
cgit v1.3

