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,9 +282,32 @@ 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={() => {
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>
<div className="flex gap-1 items-baseline"> <div className="flex gap-1 items-baseline">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl> <FormControl>
<Input <Input
key={String( key={String(
@@ -302,9 +336,8 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
p.participant === id p.participant === id
? { ? {
participant: id, participant: id,
shares: Number( shares:
event.target.value, event.target.value,
),
} }
: p, : p,
), ),
@@ -314,29 +347,17 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
step={1} step={1}
/> />
</FormControl> </FormControl>
<span {[
className={cn('text-sm', { 'BY_SHARES',
'text-muted': !field.value?.some( 'BY_PERCENTAGE',
({ participant }) => ].includes(
participant === id, form.getValues().splitMode,
), ) && sharesLabel}
})}
>
{match(form.getValues().splitMode)
.with('EVENLY', () => <></>)
.with('BY_SHARES', () => (
<>share(s)</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.exhaustive()}
</span>
</div> </div>
<FormMessage className="float-right" /> <FormMessage className="float-right" />
</div> </div>
)} )
}}
/> />
)} )}
</div> </div>
@@ -355,7 +376,8 @@ 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>
<div className="grid sm:grid-cols-2 gap-6 pt-3">
<FormField <FormField
control={form.control} control={form.control}
name="splitMode" name="splitMode"
@@ -384,9 +406,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
<SelectItem value="BY_PERCENTAGE"> <SelectItem value="BY_PERCENTAGE">
Unevenly By percentage Unevenly By percentage
</SelectItem> </SelectItem>
{/* <SelectItem value="BY_AMOUNT"> <SelectItem value="BY_AMOUNT">
Unevenly By amount Unevenly By amount
</SelectItem> */} </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -396,6 +418,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
</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})`,
@@ -128,6 +143,7 @@ export const expenseFormSchema = z
} }
break break
} }
}
}) })
export type ExpenseFormValues = z.infer<typeof expenseFormSchema> export type ExpenseFormValues = z.infer<typeof expenseFormSchema>