mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 01:19:29 +01:00
Split unevenly by amount
This commit is contained in:
@@ -60,7 +60,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||
participant: participantId,
|
||||
shares,
|
||||
shares: String(shares / 100) as unknown as number,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
isReimbursement: expense.isReimbursement,
|
||||
@@ -74,12 +74,18 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
searchParams.get('to')
|
||||
? { participant: searchParams.get('to')!, shares: 1 }
|
||||
? { participant: searchParams.get('to')! }
|
||||
: undefined,
|
||||
],
|
||||
isReimbursement: true,
|
||||
}
|
||||
: { title: '', amount: 0, paidFor: [], isReimbursement: false },
|
||||
: {
|
||||
title: '',
|
||||
amount: 0,
|
||||
paidFor: [],
|
||||
isReimbursement: false,
|
||||
splitMode: 'EVENLY',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -202,7 +208,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
? []
|
||||
: group.participants.map((p) => ({
|
||||
participant: p.id,
|
||||
shares: 1,
|
||||
shares:
|
||||
paidFor.find((pfor) => pfor.participant === p.id)
|
||||
?.shares ?? ('1' as unknown as number),
|
||||
}))
|
||||
form.setValue('paidFor', newPaidFor, {
|
||||
shouldDirty: true,
|
||||
@@ -252,7 +260,10 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
{ participant: id, shares: 1 },
|
||||
{
|
||||
participant: id,
|
||||
shares: '1',
|
||||
},
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
@@ -271,72 +282,82 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => (
|
||||
<div>
|
||||
<div className="flex gap-1 items-baseline">
|
||||
<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: Number(
|
||||
event.target.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('EVENLY', () => <></>)
|
||||
.with('BY_SHARES', () => (
|
||||
<>share(s)</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</span>
|
||||
)}
|
||||
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>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -355,47 +376,49 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
||||
Advanced splitting options…
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent 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">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select how to split the expense.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
|
||||
@@ -70,13 +70,24 @@ export const expenseFormSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
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.')
|
||||
.superRefine((paidFor, ctx) => {
|
||||
let sum = 0
|
||||
for (const { participant, shares } of paidFor) {
|
||||
for (const { shares } of paidFor) {
|
||||
sum += shares
|
||||
if (shares < 1) {
|
||||
ctx.addIssue({
|
||||
@@ -95,20 +106,21 @@ export const expenseFormSchema = z
|
||||
})
|
||||
.superRefine((expense, ctx) => {
|
||||
let sum = 0
|
||||
for (const { participant, shares } of expense.paidFor) {
|
||||
sum += shares
|
||||
for (const { shares } of expense.paidFor) {
|
||||
sum +=
|
||||
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
|
||||
}
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
break // noop
|
||||
case 'BY_SHARES':
|
||||
break // noop
|
||||
case 'BY_AMOUNT':
|
||||
case 'BY_AMOUNT': {
|
||||
if (sum !== expense.amount) {
|
||||
const detail =
|
||||
sum < expense.amount
|
||||
? `${expense.amount - sum} missing`
|
||||
: `${sum - expense.amount} surplus`
|
||||
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
|
||||
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Sum of amounts must equal the expense amount (${detail}).`,
|
||||
@@ -116,10 +128,13 @@ export const expenseFormSchema = z
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'BY_PERCENTAGE':
|
||||
if (sum !== 100) {
|
||||
}
|
||||
case 'BY_PERCENTAGE': {
|
||||
if (sum !== 10000) {
|
||||
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({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Sum of percentages must equal 100 (${detail})`,
|
||||
@@ -127,6 +142,7 @@ export const expenseFormSchema = z
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user