Split unevenly by amount

This commit is contained in:
Sebastien Castiel
2024-01-08 12:03:53 -05:00
parent c9a92408d7
commit 6ff54b6d21
2 changed files with 158 additions and 119 deletions

View File

@@ -60,7 +60,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
paidBy: expense.paidById, paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({ paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId, participant: participantId,
shares, shares: String(shares / 100) as unknown as number,
})), })),
splitMode: expense.splitMode, splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement, isReimbursement: expense.isReimbursement,
@@ -74,12 +74,18 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
paidBy: searchParams.get('from') ?? undefined, paidBy: searchParams.get('from') ?? undefined,
paidFor: [ paidFor: [
searchParams.get('to') searchParams.get('to')
? { participant: searchParams.get('to')!, shares: 1 } ? { participant: searchParams.get('to')! }
: undefined, : undefined,
], ],
isReimbursement: true, isReimbursement: true,
} }
: { title: '', amount: 0, paidFor: [], isReimbursement: false }, : {
title: '',
amount: 0,
paidFor: [],
isReimbursement: false,
splitMode: 'EVENLY',
},
}) })
return ( return (
@@ -202,7 +208,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
? [] ? []
: group.participants.map((p) => ({ : group.participants.map((p) => ({
participant: p.id, participant: p.id,
shares: 1, shares:
paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number),
})) }))
form.setValue('paidFor', newPaidFor, { form.setValue('paidFor', newPaidFor, {
shouldDirty: true, shouldDirty: true,
@@ -252,7 +260,10 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
return checked return checked
? field.onChange([ ? field.onChange([
...field.value, ...field.value,
{ participant: id, shares: 1 }, {
participant: id,
shares: '1',
},
]) ])
: field.onChange( : field.onChange(
field.value?.filter( field.value?.filter(
@@ -271,72 +282,82 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
name={`paidFor[${field.value.findIndex( name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id, ({ participant }) => participant === id,
)}].shares`} )}].shares`}
render={() => ( render={() => {
<div> const sharesLabel = (
<div className="flex gap-1 items-baseline"> <span
<FormControl> className={cn('text-sm', {
<Input 'text-muted': !field.value?.some(
key={String( ({ participant }) =>
!field.value?.some( participant === id,
({ participant }) => ),
participant === id, })}
), >
)} {match(form.getValues().splitMode)
className="text-base w-[80px] -my-2" .with('BY_SHARES', () => <>share(s)</>)
type="number" .with('BY_PERCENTAGE', () => <>%</>)
disabled={ .with('BY_AMOUNT', () => (
!field.value?.some( <>{group.currency}</>
({ participant }) => ))
participant === id, .otherwise(() => (
) <></>
} ))}
value={ </span>
field.value?.find( )
({ participant }) => return (
participant === id, <div>
)?.shares <div className="flex gap-1 items-baseline">
} {form.getValues().splitMode ===
onChange={(event) => 'BY_AMOUNT' && sharesLabel}
field.onChange( <FormControl>
field.value.map((p) => <Input
p.participant === id key={String(
? { !field.value?.some(
participant: id, ({ participant }) =>
shares: Number( participant === id,
event.target.value,
),
}
: p,
), ),
) )}
} className="text-base w-[80px] -my-2"
inputMode="numeric" type="number"
step={1} disabled={
/> !field.value?.some(
</FormControl> ({ participant }) =>
<span participant === id,
className={cn('text-sm', { )
'text-muted': !field.value?.some( }
({ participant }) => value={
participant === id, field.value?.find(
), ({ participant }) =>
})} participant === id,
> )?.shares
{match(form.getValues().splitMode) }
.with('EVENLY', () => <></>) onChange={(event) =>
.with('BY_SHARES', () => ( field.onChange(
<>share(s)</> field.value.map((p) =>
)) p.participant === id
.with('BY_PERCENTAGE', () => <>%</>) ? {
.with('BY_AMOUNT', () => ( participant: id,
<>{group.currency}</> shares:
)) event.target.value,
.exhaustive()} }
</span> : p,
),
)
}
inputMode="numeric"
step={1}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
</div>
<FormMessage className="float-right" />
</div> </div>
<FormMessage className="float-right" /> )
</div> }}
)}
/> />
)} )}
</div> </div>
@@ -355,47 +376,49 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
Advanced splitting options Advanced splitting options
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="grid sm:grid-cols-2 gap-6 pt-3"> <CollapsibleContent>
<FormField <div className="grid sm:grid-cols-2 gap-6 pt-3">
control={form.control} <FormField
name="splitMode" control={form.control}
render={({ field }) => ( name="splitMode"
<FormItem className="sm:order-2"> render={({ field }) => (
<FormLabel>Split mode</FormLabel> <FormItem className="sm:order-2">
<FormControl> <FormLabel>Split mode</FormLabel>
<Select <FormControl>
onValueChange={(value) => { <Select
form.setValue('splitMode', value as any, { onValueChange={(value) => {
shouldDirty: true, form.setValue('splitMode', value as any, {
shouldTouch: true, shouldDirty: true,
shouldValidate: true, shouldTouch: true,
}) shouldValidate: true,
}} })
defaultValue={field.value} }}
> defaultValue={field.value}
<SelectTrigger> >
<SelectValue /> <SelectTrigger>
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="EVENLY">Evenly</SelectItem> <SelectContent>
<SelectItem value="BY_SHARES"> <SelectItem value="EVENLY">Evenly</SelectItem>
Unevenly By shares <SelectItem value="BY_SHARES">
</SelectItem> Unevenly By shares
<SelectItem value="BY_PERCENTAGE"> </SelectItem>
Unevenly By percentage <SelectItem value="BY_PERCENTAGE">
</SelectItem> Unevenly By percentage
{/* <SelectItem value="BY_AMOUNT"> </SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount Unevenly By amount
</SelectItem> */} </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Select how to split the expense. Select how to split the expense.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
/> />
</div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</CardContent> </CardContent>

View File

@@ -70,13 +70,24 @@ export const expenseFormSchema = z
.array( .array(
z.object({ z.object({
participant: z.string(), participant: z.string(),
shares: z.number().int(), shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
]),
}), }),
) )
.min(1, 'The expense must be paid for at least one participant.') .min(1, 'The expense must be paid for at least one participant.')
.superRefine((paidFor, ctx) => { .superRefine((paidFor, ctx) => {
let sum = 0 let sum = 0
for (const { participant, shares } of paidFor) { for (const { shares } of paidFor) {
sum += shares sum += shares
if (shares < 1) { if (shares < 1) {
ctx.addIssue({ ctx.addIssue({
@@ -95,20 +106,21 @@ export const expenseFormSchema = z
}) })
.superRefine((expense, ctx) => { .superRefine((expense, ctx) => {
let sum = 0 let sum = 0
for (const { participant, shares } of expense.paidFor) { for (const { shares } of expense.paidFor) {
sum += shares sum +=
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
} }
switch (expense.splitMode) { switch (expense.splitMode) {
case 'EVENLY': case 'EVENLY':
break // noop break // noop
case 'BY_SHARES': case 'BY_SHARES':
break // noop break // noop
case 'BY_AMOUNT': case 'BY_AMOUNT': {
if (sum !== expense.amount) { if (sum !== expense.amount) {
const detail = const detail =
sum < expense.amount sum < expense.amount
? `${expense.amount - sum} missing` ? `${((expense.amount - sum) / 100).toFixed(2)} missing`
: `${sum - expense.amount} surplus` : `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`, message: `Sum of amounts must equal the expense amount (${detail}).`,
@@ -116,10 +128,13 @@ export const expenseFormSchema = z
}) })
} }
break break
case 'BY_PERCENTAGE': }
if (sum !== 100) { case 'BY_PERCENTAGE': {
if (sum !== 10000) {
const detail = const detail =
sum < 100 ? `${100 - sum}% missing` : `${sum - 100}% surplus` sum < 10000
? `${((10000 - sum) / 100).toFixed(0)}% missing`
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`, message: `Sum of percentages must equal 100 (${detail})`,
@@ -127,6 +142,7 @@ export const expenseFormSchema = z
}) })
} }
break break
}
} }
}) })