From 0c054991070e4e9c177de17d06dff7cb020ca226 Mon Sep 17 00:00:00 2001 From: Stefan Hynst Date: Thu, 30 May 2024 04:20:04 +0200 Subject: [PATCH] Add support for group income (= negative expenses) (#158) * Allow negative amount for expenses to be entered - an expense becomes an income - this does not affect calculations, i.e. an income can be split just like an expense * Incomes should not be reimbursements when entering a negative number - deselect 'isReimbursement' - hide reimbursement checkbox * Change captions when entering a negative number - "expense" becomes "income" - "paid" becomes "received" * Format incomes on expense list - replace "paid by" with "received by" * Format incomes on "Stats" tab - a group's or participants balance might be negative - in this case "spendings" will be "earnings" (display accordingly) - always display positive numbers - for active user: highlight spendings/earnings in red/green * Fix typo --------- Co-authored-by: Sebastien Castiel --- .../[groupId]/expenses/expense-card.tsx | 3 +- .../[groupId]/stats/totals-group-spending.tsx | 5 +- .../[groupId]/stats/totals-your-share.tsx | 11 ++- .../[groupId]/stats/totals-your-spending.tsx | 14 ++- src/components/expense-form.tsx | 95 ++++++++++--------- src/lib/schemas.ts | 2 +- 6 files changed, 75 insertions(+), 55 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/expense-card.tsx b/src/app/groups/[groupId]/expenses/expense-card.tsx index f4a5f28..239f8fd 100644 --- a/src/app/groups/[groupId]/expenses/expense-card.tsx +++ b/src/app/groups/[groupId]/expenses/expense-card.tsx @@ -38,7 +38,8 @@ export function ExpenseCard({ expense, currency, groupId }: Props) { {expense.title}
- Paid by {expense.paidBy.name} for{' '} + {expense.amount > 0 ? 'Paid by ' : 'Received by '} + {expense.paidBy.name} for{' '} {expense.paidFor.map((paidFor, index) => ( {index !== 0 && <>, } diff --git a/src/app/groups/[groupId]/stats/totals-group-spending.tsx b/src/app/groups/[groupId]/stats/totals-group-spending.tsx index 3c0c96d..7cb35da 100644 --- a/src/app/groups/[groupId]/stats/totals-group-spending.tsx +++ b/src/app/groups/[groupId]/stats/totals-group-spending.tsx @@ -6,11 +6,12 @@ type Props = { } export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { + const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings' return (
-
Total group spendings
+
Total group {balance}
- {formatCurrency(currency, totalGroupSpendings)} + {formatCurrency(currency, Math.abs(totalGroupSpendings))}
) diff --git a/src/app/groups/[groupId]/stats/totals-your-share.tsx b/src/app/groups/[groupId]/stats/totals-your-share.tsx index 3218d6f..3c7b7cb 100644 --- a/src/app/groups/[groupId]/stats/totals-your-share.tsx +++ b/src/app/groups/[groupId]/stats/totals-your-share.tsx @@ -1,7 +1,7 @@ 'use client' import { getGroup, getGroupExpenses } from '@/lib/api' import { getTotalActiveUserShare } from '@/lib/totals' -import { formatCurrency } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' import { useEffect, useState } from 'react' type Props = { @@ -26,8 +26,13 @@ export function TotalsYourShare({ group, expenses }: Props) { return (
Your total share
-
- {formatCurrency(currency, totalActiveUserShare)} +
+ {formatCurrency(currency, Math.abs(totalActiveUserShare))}
) diff --git a/src/app/groups/[groupId]/stats/totals-your-spending.tsx b/src/app/groups/[groupId]/stats/totals-your-spending.tsx index f621a41..137574b 100644 --- a/src/app/groups/[groupId]/stats/totals-your-spending.tsx +++ b/src/app/groups/[groupId]/stats/totals-your-spending.tsx @@ -2,7 +2,7 @@ import { getGroup, getGroupExpenses } from '@/lib/api' import { useActiveUser } from '@/lib/hooks' import { getTotalActiveUserPaidFor } from '@/lib/totals' -import { formatCurrency } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' type Props = { group: NonNullable>> @@ -17,13 +17,19 @@ export function TotalsYourSpendings({ group, expenses }: Props) { ? 0 : getTotalActiveUserPaidFor(activeUser, expenses) const currency = group.currency + const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings' return (
-
Total you paid for
+
Your total {balance}
-
- {formatCurrency(currency, totalYourSpendings)} +
+ {formatCurrency(currency, Math.abs(totalYourSpendings))}
) diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index fbc0099..758e93d 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -64,14 +64,15 @@ export type Props = { const enforceCurrencyPattern = (value: string) => value - // replace first comma with # - .replace(/[.,]/, '#') - // remove all other commas - .replace(/[.,]/g, '') - // change back # to dot - .replace(/#/, '.') - // remove all non-numeric and non-dot characters - .replace(/[^\d.]/g, '') + .replace(/^\s*-/, '_') // replace leading minus with _ + .replace(/[.,]/, '#') // replace first comma with # + .replace(/[-.,]/g, '') // remove other minus and commas characters + .replace(/_/, '-') // change back _ to minus + .replace(/#/, '.') // change back # to dot + .replace(/[^-\d.]/g, '') // remove all non-numeric characters + +const capitalize = (value: string) => + value.charAt(0).toUpperCase() + value.slice(1) const getDefaultSplittingOptions = (group: Props['group']) => { const defaultValue = { @@ -243,14 +244,16 @@ export function ExpenseForm({ return onSubmit(values, activeUserId ?? undefined) } + const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0) + const sExpense = isIncome ? 'income' : 'expense' + const sPaid = isIncome ? 'received' : 'paid' + return (
- - {isCreate ? <>Create expense : <>Edit expense} - + {(isCreate ? 'Create ' : 'Edit ') + sExpense} ( - Expense title + {capitalize(sExpense)} title - Enter a description for the expense. + Enter a description for the {sExpense}. @@ -290,7 +293,7 @@ export function ExpenseForm({ name="expenseDate" render={({ field }) => ( - Expense date + {capitalize(sExpense)} date - Enter the date the expense was made. + Enter the date the {sExpense} was {sPaid}. @@ -319,15 +322,17 @@ export function ExpenseForm({ {group.currency} - onChange(enforceCurrencyPattern(event.target.value)) - } + onChange={(event) => { + const v = enforceCurrencyPattern(event.target.value) + const income = Number(v) < 0 + setIsIncome(income) + if (income) form.setValue('isReimbursement', false) + onChange(v) + }} onFocus={(e) => { // we're adding a small delay to get around safaris issue with onMouseUp deselecting things again const target = e.currentTarget @@ -339,23 +344,25 @@ export function ExpenseForm({
- ( - - - - -
- This is a reimbursement -
-
- )} - /> + {!isIncome && ( + ( + + + + +
+ This is a reimbursement +
+
+ )} + /> + )} )} /> @@ -375,7 +382,7 @@ export function ExpenseForm({ isLoading={isCategoryLoading} /> - Select the expense category. + Select the {sExpense} category. @@ -387,7 +394,7 @@ export function ExpenseForm({ name="paidBy" render={({ field }) => ( - Paid by + {capitalize(sPaid)} by - Select the participant who paid the expense. + Select the participant who {sPaid} the {sExpense}. @@ -428,7 +435,7 @@ export function ExpenseForm({ - Paid for + {capitalize(sPaid)} for