mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-05 20:26:11 +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,
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user