mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-18 13:36:12 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec | ||
|
|
b67a0be0dd | ||
|
|
e07d237218 | ||
|
|
cc37083389 | ||
|
|
552953151a | ||
|
|
b227401dd6 | ||
|
|
6a5efc5f3f | ||
|
|
4c5f8a6aa5 | ||
|
|
c2b591349b | ||
|
|
56c1865264 | ||
|
|
2f991e680b |
@@ -10,12 +10,13 @@ COPY ./package.json \
|
|||||||
./postcss.config.js ./
|
./postcss.config.js ./
|
||||||
COPY ./scripts ./scripts
|
COPY ./scripts ./scripts
|
||||||
COPY ./prisma ./prisma
|
COPY ./prisma ./prisma
|
||||||
COPY ./src ./src
|
|
||||||
|
|
||||||
RUN apk add --no-cache openssl && \
|
RUN apk add --no-cache openssl && \
|
||||||
npm ci --ignore-scripts && \
|
npm ci --ignore-scripts && \
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
|
|
||||||
|
COPY ./src ./src
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY scripts/build.env .env
|
COPY scripts/build.env .env
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"check-formatting": "prettier -c src",
|
"check-formatting": "prettier -c src",
|
||||||
|
"prettier": "prettier -w src",
|
||||||
"postinstall": "prisma migrate deploy && prisma generate",
|
"postinstall": "prisma migrate deploy && prisma generate",
|
||||||
"build-image": "./scripts/build-image.sh",
|
"build-image": "./scripts/build-image.sh",
|
||||||
"start-container": "docker compose --env-file container.env up"
|
"start-container": "docker compose --env-file container.env up"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||||
@@ -52,6 +52,7 @@ model Expense {
|
|||||||
splitMode SplitMode @default(EVENLY)
|
splitMode SplitMode @default(EVENLY)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
documents ExpenseDocument[]
|
documents ExpenseDocument[]
|
||||||
|
notes String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model ExpenseDocument {
|
model ExpenseDocument {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXPENSE_GROUPS = {
|
const EXPENSE_GROUPS = {
|
||||||
|
UPCOMING: 'Upcoming',
|
||||||
THIS_WEEK: 'This week',
|
THIS_WEEK: 'This week',
|
||||||
EARLIER_THIS_MONTH: 'Earlier this month',
|
EARLIER_THIS_MONTH: 'Earlier this month',
|
||||||
LAST_MONTH: 'Last month',
|
LAST_MONTH: 'Last month',
|
||||||
@@ -28,7 +29,9 @@ const EXPENSE_GROUPS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||||
if (today.isSame(date, 'week')) {
|
if (today.isBefore(date)) {
|
||||||
|
return EXPENSE_GROUPS.UPCOMING
|
||||||
|
} else if (today.isSame(date, 'week')) {
|
||||||
return EXPENSE_GROUPS.THIS_WEEK
|
return EXPENSE_GROUPS.THIS_WEEK
|
||||||
} else if (today.isSame(date, 'month')) {
|
} else if (today.isSame(date, 'month')) {
|
||||||
return EXPENSE_GROUPS.EARLIER_THIS_MONTH
|
return EXPENSE_GROUPS.EARLIER_THIS_MONTH
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default async function GroupExpensesPage({
|
|||||||
prefetch={false}
|
prefetch={false}
|
||||||
href={`/groups/${groupId}/expenses/export/json`}
|
href={`/groups/${groupId}/expenses/export/json`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
title="Export to JSON"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -63,7 +64,10 @@ export default async function GroupExpensesPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button asChild size="icon">
|
<Button asChild size="icon">
|
||||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
<Link
|
||||||
|
href={`/groups/${groupId}/expenses/create`}
|
||||||
|
title="Create expense"
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function GroupTabs({ groupId }: Props) {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
<TabsTrigger value="balances">Balances</TabsTrigger>
|
||||||
|
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
<TabsTrigger value="edit">Settings</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button size="icon">
|
<Button title="Share" size="icon">
|
||||||
<Share className="w-4 h-4" />
|
<Share className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
49
src/app/groups/[groupId]/stats/page.tsx
Normal file
49
src/app/groups/[groupId]/stats/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cached } from '@/app/cached-functions'
|
||||||
|
import { Totals } from '@/app/groups/[groupId]/stats/totals'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
import { getTotalGroupSpending } from '@/lib/totals'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Totals',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TotalsPage({
|
||||||
|
params: { groupId },
|
||||||
|
}: {
|
||||||
|
params: { groupId: string }
|
||||||
|
}) {
|
||||||
|
const group = await cached.getGroup(groupId)
|
||||||
|
if (!group) notFound()
|
||||||
|
|
||||||
|
const expenses = await getGroupExpenses(groupId)
|
||||||
|
const totalGroupSpendings = getTotalGroupSpending(expenses)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Totals</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Spending summary of the entire group.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col space-y-4">
|
||||||
|
<Totals
|
||||||
|
group={group}
|
||||||
|
expenses={expenses}
|
||||||
|
totalGroupSpendings={totalGroupSpendings}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/groups/[groupId]/stats/totals-group-spending.tsx
Normal file
17
src/app/groups/[groupId]/stats/totals-group-spending.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { formatCurrency } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
totalGroupSpendings: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Total group spendings</div>
|
||||||
|
<div className="text-lg">
|
||||||
|
{formatCurrency(currency, totalGroupSpendings)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/app/groups/[groupId]/stats/totals-your-share.tsx
Normal file
34
src/app/groups/[groupId]/stats/totals-your-share.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||||
|
import { getTotalActiveUserShare } from '@/lib/totals'
|
||||||
|
import { formatCurrency } from '@/lib/utils'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TotalsYourShare({ group, expenses }: Props) {
|
||||||
|
const [activeUser, setActiveUser] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||||
|
if (activeUser) setActiveUser(activeUser)
|
||||||
|
}, [group, expenses])
|
||||||
|
|
||||||
|
const totalActiveUserShare =
|
||||||
|
activeUser === '' || activeUser === 'None'
|
||||||
|
? 0
|
||||||
|
: getTotalActiveUserShare(activeUser, expenses)
|
||||||
|
const currency = group.currency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Your total share</div>
|
||||||
|
<div className="text-lg">
|
||||||
|
{formatCurrency(currency, totalActiveUserShare)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/app/groups/[groupId]/stats/totals-your-spending.tsx
Normal file
30
src/app/groups/[groupId]/stats/totals-your-spending.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||||
|
import { useActiveUser } from '@/lib/hooks'
|
||||||
|
import { getTotalActiveUserPaidFor } from '@/lib/totals'
|
||||||
|
import { formatCurrency } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TotalsYourSpendings({ group, expenses }: Props) {
|
||||||
|
const activeUser = useActiveUser(group.id)
|
||||||
|
|
||||||
|
const totalYourSpendings =
|
||||||
|
activeUser === '' || activeUser === 'None'
|
||||||
|
? 0
|
||||||
|
: getTotalActiveUserPaidFor(activeUser, expenses)
|
||||||
|
const currency = group.currency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Total you paid for</div>
|
||||||
|
|
||||||
|
<div className="text-lg">
|
||||||
|
{formatCurrency(currency, totalYourSpendings)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/app/groups/[groupId]/stats/totals.tsx
Normal file
34
src/app/groups/[groupId]/stats/totals.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
|
||||||
|
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
|
||||||
|
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
||||||
|
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||||
|
import { useActiveUser } from '@/lib/hooks'
|
||||||
|
|
||||||
|
export function Totals({
|
||||||
|
group,
|
||||||
|
expenses,
|
||||||
|
totalGroupSpendings,
|
||||||
|
}: {
|
||||||
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||||
|
totalGroupSpendings: number
|
||||||
|
}) {
|
||||||
|
const activeUser = useActiveUser(group.id)
|
||||||
|
console.log('activeUser', activeUser)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TotalsGroupSpending
|
||||||
|
totalGroupSpendings={totalGroupSpendings}
|
||||||
|
currency={group.currency}
|
||||||
|
/>
|
||||||
|
{activeUser && activeUser !== 'None' && (
|
||||||
|
<>
|
||||||
|
<TotalsYourSpendings group={group} expenses={expenses} />
|
||||||
|
<TotalsYourShare group={group} expenses={expenses} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/components/delete-popup.tsx
Normal file
47
src/components/delete-popup.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { AsyncButton } from './async-button'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from './ui/dialog'
|
||||||
|
|
||||||
|
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>Delete this expense?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Do you really want to delete this expense? This action is
|
||||||
|
irreversible.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogFooter className="flex flex-col gap-2">
|
||||||
|
<AsyncButton
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
loadingContent="Deleting…"
|
||||||
|
action={onDelete}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</AsyncButton>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant={'secondary'}>Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { AsyncButton } from '@/components/async-button'
|
|
||||||
import { CategorySelector } from '@/components/category-selector'
|
import { CategorySelector } from '@/components/category-selector'
|
||||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||||
import { SubmitButton } from '@/components/submit-button'
|
import { SubmitButton } from '@/components/submit-button'
|
||||||
@@ -36,16 +35,22 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
|
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
|
||||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||||
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
import {
|
||||||
|
ExpenseFormValues,
|
||||||
|
SplittingOptions,
|
||||||
|
expenseFormSchema,
|
||||||
|
} from '@/lib/schemas'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { Save, Trash2 } from 'lucide-react'
|
import { Save } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { match } from 'ts-pattern'
|
import { match } from 'ts-pattern'
|
||||||
|
import { DeletePopup } from './delete-popup'
|
||||||
import { extractCategoryFromTitle } from './expense-form-actions'
|
import { extractCategoryFromTitle } from './expense-form-actions'
|
||||||
|
import { Textarea } from './ui/textarea'
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
@@ -56,6 +61,89 @@ export type Props = {
|
|||||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, '')
|
||||||
|
|
||||||
|
const getDefaultSplittingOptions = (group: Props['group']) => {
|
||||||
|
const defaultValue = {
|
||||||
|
splitMode: 'EVENLY' as const,
|
||||||
|
paidFor: group.participants.map(({ id }) => ({
|
||||||
|
participant: id,
|
||||||
|
shares: '1' as unknown as number,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof localStorage === 'undefined') return defaultValue
|
||||||
|
const defaultSplitMode = localStorage.getItem(
|
||||||
|
`${group.id}-defaultSplittingOptions`,
|
||||||
|
)
|
||||||
|
if (defaultSplitMode === null) return defaultValue
|
||||||
|
const parsedDefaultSplitMode = JSON.parse(
|
||||||
|
defaultSplitMode,
|
||||||
|
) as SplittingOptions
|
||||||
|
|
||||||
|
if (parsedDefaultSplitMode.paidFor === null) {
|
||||||
|
parsedDefaultSplitMode.paidFor = defaultValue.paidFor
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a participant in the default options that does not exist anymore,
|
||||||
|
// remove the stale default splitting options
|
||||||
|
for (const parsedPaidFor of parsedDefaultSplitMode.paidFor) {
|
||||||
|
if (
|
||||||
|
!group.participants.some(({ id }) => id === parsedPaidFor.participant)
|
||||||
|
) {
|
||||||
|
localStorage.removeItem(`${group.id}-defaultSplittingOptions`)
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
splitMode: parsedDefaultSplitMode.splitMode,
|
||||||
|
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
||||||
|
participant: paidFor.participant,
|
||||||
|
shares: String(paidFor.shares / 100) as unknown as number,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistDefaultSplittingOptions(
|
||||||
|
groupId: string,
|
||||||
|
expenseFormValues: ExpenseFormValues,
|
||||||
|
) {
|
||||||
|
if (localStorage && expenseFormValues.saveDefaultSplittingOptions) {
|
||||||
|
const computePaidFor = (): SplittingOptions['paidFor'] => {
|
||||||
|
if (expenseFormValues.splitMode === 'EVENLY') {
|
||||||
|
return expenseFormValues.paidFor.map(({ participant }) => ({
|
||||||
|
participant,
|
||||||
|
shares: '100' as unknown as number,
|
||||||
|
}))
|
||||||
|
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
return expenseFormValues.paidFor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const splittingOptions = {
|
||||||
|
splitMode: expenseFormValues.splitMode,
|
||||||
|
paidFor: computePaidFor(),
|
||||||
|
} satisfies SplittingOptions
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
`${groupId}-defaultSplittingOptions`,
|
||||||
|
JSON.stringify(splittingOptions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ExpenseForm({
|
export function ExpenseForm({
|
||||||
group,
|
group,
|
||||||
expense,
|
expense,
|
||||||
@@ -69,12 +157,13 @@ export function ExpenseForm({
|
|||||||
const getSelectedPayer = (field?: { value: string }) => {
|
const getSelectedPayer = (field?: { value: string }) => {
|
||||||
if (isCreate && typeof window !== 'undefined') {
|
if (isCreate && typeof window !== 'undefined') {
|
||||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||||
if (activeUser && activeUser !== 'None') {
|
if (activeUser && activeUser !== 'None' && field?.value === undefined) {
|
||||||
return activeUser
|
return activeUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return field?.value
|
return field?.value
|
||||||
}
|
}
|
||||||
|
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||||
const form = useForm<ExpenseFormValues>({
|
const form = useForm<ExpenseFormValues>({
|
||||||
resolver: zodResolver(expenseFormSchema),
|
resolver: zodResolver(expenseFormSchema),
|
||||||
defaultValues: expense
|
defaultValues: expense
|
||||||
@@ -89,8 +178,10 @@ export function ExpenseForm({
|
|||||||
shares: String(shares / 100) as unknown as number,
|
shares: String(shares / 100) as unknown as number,
|
||||||
})),
|
})),
|
||||||
splitMode: expense.splitMode,
|
splitMode: expense.splitMode,
|
||||||
|
saveDefaultSplittingOptions: false,
|
||||||
isReimbursement: expense.isReimbursement,
|
isReimbursement: expense.isReimbursement,
|
||||||
documents: expense.documents,
|
documents: expense.documents,
|
||||||
|
notes: expense.notes ?? '',
|
||||||
}
|
}
|
||||||
: searchParams.get('reimbursement')
|
: searchParams.get('reimbursement')
|
||||||
? {
|
? {
|
||||||
@@ -103,12 +194,17 @@ export function ExpenseForm({
|
|||||||
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')!,
|
||||||
|
shares: '1' as unknown as number,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
],
|
],
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
splitMode: 'EVENLY',
|
splitMode: defaultSplittingOptions.splitMode,
|
||||||
|
saveDefaultSplittingOptions: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
|
notes: '',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: searchParams.get('title') ?? '',
|
title: searchParams.get('title') ?? '',
|
||||||
@@ -120,13 +216,11 @@ export function ExpenseForm({
|
|||||||
? Number(searchParams.get('categoryId'))
|
? Number(searchParams.get('categoryId'))
|
||||||
: 0, // category with Id 0 is General
|
: 0, // category with Id 0 is General
|
||||||
// paid for all, split evenly
|
// paid for all, split evenly
|
||||||
paidFor: group.participants.map(({ id }) => ({
|
paidFor: defaultSplittingOptions.paidFor,
|
||||||
participant: id,
|
|
||||||
shares: 1,
|
|
||||||
})),
|
|
||||||
paidBy: getSelectedPayer(),
|
paidBy: getSelectedPayer(),
|
||||||
isReimbursement: false,
|
isReimbursement: false,
|
||||||
splitMode: 'EVENLY',
|
splitMode: defaultSplittingOptions.splitMode,
|
||||||
|
saveDefaultSplittingOptions: false,
|
||||||
documents: searchParams.get('imageUrl')
|
documents: searchParams.get('imageUrl')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -137,13 +231,19 @@ export function ExpenseForm({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
|
notes: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (values: ExpenseFormValues) => {
|
||||||
|
await persistDefaultSplittingOptions(group.id, values)
|
||||||
|
return onSubmit(values)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
|
<form onSubmit={form.handleSubmit(submit)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
@@ -210,18 +310,23 @@ export function ExpenseForm({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="amount"
|
name="amount"
|
||||||
render={({ field }) => (
|
render={({ field: { onChange, ...field } }) => (
|
||||||
<FormItem className="sm:order-3">
|
<FormItem className="sm:order-3">
|
||||||
<FormLabel>Amount</FormLabel>
|
<FormLabel>Amount</FormLabel>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span>{group.currency}</span>
|
<span>{group.currency}</span>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
{...field}
|
||||||
className="text-base max-w-[120px]"
|
className="text-base max-w-[120px]"
|
||||||
type="number"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step={0.01}
|
step={0.01}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange(enforceCurrencyPattern(event.target.value))
|
||||||
|
}
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -299,6 +404,18 @@ export function ExpenseForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="sm:order-6">
|
||||||
|
<FormLabel>Notes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea className="text-base" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -427,7 +544,7 @@ export function ExpenseForm({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
className="text-base w-[80px] -my-2"
|
className="text-base w-[80px] -my-2"
|
||||||
type="number"
|
type="text"
|
||||||
disabled={
|
disabled={
|
||||||
!field.value?.some(
|
!field.value?.some(
|
||||||
({ participant }) =>
|
({ participant }) =>
|
||||||
@@ -447,7 +564,9 @@ export function ExpenseForm({
|
|||||||
? {
|
? {
|
||||||
participant: id,
|
participant: id,
|
||||||
shares:
|
shares:
|
||||||
event.target.value,
|
enforceCurrencyPattern(
|
||||||
|
event.target.value,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
@@ -490,7 +609,10 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapsible className="mt-5">
|
<Collapsible
|
||||||
|
className="mt-5"
|
||||||
|
defaultOpen={form.getValues().splitMode !== 'EVENLY'}
|
||||||
|
>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="link" className="-mx-4">
|
<Button variant="link" className="-mx-4">
|
||||||
Advanced splitting options…
|
Advanced splitting options…
|
||||||
@@ -502,7 +624,7 @@ export function ExpenseForm({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="splitMode"
|
name="splitMode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="sm:order-2">
|
<FormItem>
|
||||||
<FormLabel>Split mode</FormLabel>
|
<FormLabel>Split mode</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
@@ -538,6 +660,25 @@ export function ExpenseForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="saveDefaultSplittingOptions"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div>
|
||||||
|
<FormLabel>
|
||||||
|
Save as default splitting options
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
@@ -577,15 +718,7 @@ export function ExpenseForm({
|
|||||||
{isCreate ? <>Create</> : <>Save</>}
|
{isCreate ? <>Create</> : <>Save</>}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{!isCreate && onDelete && (
|
{!isCreate && onDelete && (
|
||||||
<AsyncButton
|
<DeletePopup onDelete={onDelete}></DeletePopup>
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
loadingContent="Deleting…"
|
|
||||||
action={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</AsyncButton>
|
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export async function createExpense(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
notes: expenseFormValues.notes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -185,6 +186,7 @@ export async function updateExpense(
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
notes: expenseFormValues.notes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,3 +48,17 @@ export function useBaseUrl() {
|
|||||||
}, [])
|
}, [])
|
||||||
return baseUrl
|
return baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns The active user, or `null` until it is fetched from local storage
|
||||||
|
*/
|
||||||
|
export function useActiveUser(groupId: string) {
|
||||||
|
const [activeUser, setActiveUser] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
|
||||||
|
if (activeUser) setActiveUser(activeUser)
|
||||||
|
}, [groupId])
|
||||||
|
|
||||||
|
return activeUser
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ export const expenseFormSchema = z
|
|||||||
[
|
[
|
||||||
z.number(),
|
z.number(),
|
||||||
z.string().transform((value, ctx) => {
|
z.string().transform((value, ctx) => {
|
||||||
const normalizedValue = value.replace(/,/g, '.')
|
const valueAsNumber = Number(value)
|
||||||
const valueAsNumber = Number(normalizedValue)
|
|
||||||
if (Number.isNaN(valueAsNumber))
|
if (Number.isNaN(valueAsNumber))
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -106,6 +105,7 @@ export const expenseFormSchema = z
|
|||||||
Object.values(SplitMode) as any,
|
Object.values(SplitMode) as any,
|
||||||
)
|
)
|
||||||
.default('EVENLY'),
|
.default('EVENLY'),
|
||||||
|
saveDefaultSplittingOptions: z.boolean(),
|
||||||
isReimbursement: z.boolean(),
|
isReimbursement: z.boolean(),
|
||||||
documents: z
|
documents: z
|
||||||
.array(
|
.array(
|
||||||
@@ -117,6 +117,7 @@ export const expenseFormSchema = z
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((expense, ctx) => {
|
.superRefine((expense, ctx) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
@@ -161,3 +162,9 @@ export const expenseFormSchema = z
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
||||||
|
|
||||||
|
export type SplittingOptions = {
|
||||||
|
// Used for saving default splitting options in localStorage
|
||||||
|
splitMode: SplitMode
|
||||||
|
paidFor: ExpenseFormValues['paidFor'] | null
|
||||||
|
}
|
||||||
|
|||||||
70
src/lib/totals.ts
Normal file
70
src/lib/totals.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
|
||||||
|
export function getTotalGroupSpending(
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||||
|
): number {
|
||||||
|
return expenses.reduce(
|
||||||
|
(total, expense) =>
|
||||||
|
expense.isReimbursement ? total : total + expense.amount,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalActiveUserPaidFor(
|
||||||
|
activeUserId: string | null,
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||||
|
): number {
|
||||||
|
return expenses.reduce(
|
||||||
|
(total, expense) =>
|
||||||
|
expense.paidBy.id === activeUserId && !expense.isReimbursement
|
||||||
|
? total + expense.amount
|
||||||
|
: total,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalActiveUserShare(
|
||||||
|
activeUserId: string | null,
|
||||||
|
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||||
|
): number {
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
expenses.forEach((expense) => {
|
||||||
|
if (expense.isReimbursement) return
|
||||||
|
|
||||||
|
const paidFors = expense.paidFor
|
||||||
|
const userPaidFor = paidFors.find(
|
||||||
|
(paidFor) => paidFor.participantId === activeUserId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!userPaidFor) {
|
||||||
|
// If the active user is not involved in the expense, skip it
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (expense.splitMode) {
|
||||||
|
case 'EVENLY':
|
||||||
|
// Divide the total expense evenly among all participants
|
||||||
|
total += expense.amount / paidFors.length
|
||||||
|
break
|
||||||
|
case 'BY_AMOUNT':
|
||||||
|
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||||
|
total += userPaidFor.shares
|
||||||
|
break
|
||||||
|
case 'BY_PERCENTAGE':
|
||||||
|
// Calculate the user's share based on their percentage of the total expense
|
||||||
|
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||||
|
break
|
||||||
|
case 'BY_SHARES':
|
||||||
|
// Calculate the user's share based on their shares relative to the total shares
|
||||||
|
const totalShares = paidFors.reduce(
|
||||||
|
(sum, paidFor) => sum + paidFor.shares,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
total += (expense.amount * userPaidFor.shares) / totalShares
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return parseFloat(total.toFixed(2))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user