6 Commits
1.4.0 ... 1.6.0

Author SHA1 Message Date
Deep Golani
2fd38aadd9 Add notes in expense (#126)
* Feature: Added notes in expense

* Add missing notes in form values

* Prettier

---------

Co-authored-by: deep.golani <deep.golani@bfhl.in>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-05 08:38:38 -04:00
magomzr
b61d1836ea Add titles for a better user experience (#137)
Co-authored-by: Mario Gómez <60667991+mgomezarr@users.noreply.github.com>
2024-04-05 08:29:08 -04:00
Sahil Mehra
c3903849ec Bug: Fixed wrong paid by Name in Reimbursement (#134) 2024-04-02 08:20:56 -04:00
Jan T
b67a0be0dd Add "save as default splitting options" feature (#120)
* Add "save as default splitting options" feature

* Fix type issue

* Run autoformatter
2024-03-09 11:45:53 -05:00
Guhan
e07d237218 Ask for confirmation to delete an expense (#124)
* feat: added a popup asking for confirmation to delete an expense

* fix: changed cancel option as a button and formatting issues

* fix: removed unnecessary tags and replaced generic tags with proper components

* Small fix to avoid warning in console

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-03-09 11:38:30 -05:00
annalouisep
cc37083389 Expense list: add section for planned purchases (#122)
* add planned purchases

* Updating verbiage to reflect possible future entry types
2024-03-09 11:30:24 -05:00
9 changed files with 204 additions and 25 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;

View File

@@ -52,6 +52,7 @@ model Expense {
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
}
model ExpenseDocument {

View File

@@ -19,6 +19,7 @@ type Props = {
}
const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming',
THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
@@ -28,7 +29,9 @@ const EXPENSE_GROUPS = {
}
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
} else if (today.isSame(date, 'month')) {
return EXPENSE_GROUPS.EARLIER_THIS_MONTH

View File

@@ -51,6 +51,7 @@ export default async function GroupExpensesPage({
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title="Export to JSON"
>
<Download className="w-4 h-4" />
</Link>
@@ -63,7 +64,10 @@ export default async function GroupExpensesPage({
/>
)}
<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" />
</Link>
</Button>

View File

@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
return (
<Popover>
<PopoverTrigger asChild>
<Button size="icon">
<Button title="Share" size="icon">
<Share className="w-4 h-4" />
</Button>
</PopoverTrigger>

View 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>
)
}

View File

@@ -1,5 +1,4 @@
'use client'
import { AsyncButton } from '@/components/async-button'
import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
@@ -36,16 +35,22 @@ import {
} from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import {
ExpenseFormValues,
SplittingOptions,
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { Save } from 'lucide-react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -67,6 +72,78 @@ const enforceCurrencyPattern = (value: string) =>
// 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({
group,
expense,
@@ -80,12 +157,13 @@ export function ExpenseForm({
const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser && activeUser !== 'None') {
if (activeUser && activeUser !== 'None' && field?.value === undefined) {
return activeUser
}
}
return field?.value
}
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
@@ -100,8 +178,10 @@ export function ExpenseForm({
shares: String(shares / 100) as unknown as number,
})),
splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
isReimbursement: expense.isReimbursement,
documents: expense.documents,
notes: expense.notes ?? '',
}
: searchParams.get('reimbursement')
? {
@@ -121,8 +201,10 @@ export function ExpenseForm({
: undefined,
],
isReimbursement: true,
splitMode: 'EVENLY',
splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: [],
notes: '',
}
: {
title: searchParams.get('title') ?? '',
@@ -134,13 +216,11 @@ export function ExpenseForm({
? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General
// paid for all, split evenly
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: '1' as unknown as number,
})),
paidFor: defaultSplittingOptions.paidFor,
paidBy: getSelectedPayer(),
isReimbursement: false,
splitMode: 'EVENLY',
splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: searchParams.get('imageUrl')
? [
{
@@ -151,13 +231,19 @@ export function ExpenseForm({
},
]
: [],
notes: '',
},
})
const [isCategoryLoading, setCategoryLoading] = useState(false)
const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values)
return onSubmit(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
<form onSubmit={form.handleSubmit(submit)}>
<Card>
<CardHeader>
<CardTitle>
@@ -318,6 +404,18 @@ export function ExpenseForm({
</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>
</Card>
@@ -511,7 +609,10 @@ export function ExpenseForm({
)}
/>
<Collapsible className="mt-5">
<Collapsible
className="mt-5"
defaultOpen={form.getValues().splitMode !== 'EVENLY'}
>
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
Advanced splitting options
@@ -523,7 +624,7 @@ export function ExpenseForm({
control={form.control}
name="splitMode"
render={({ field }) => (
<FormItem className="sm:order-2">
<FormItem>
<FormLabel>Split mode</FormLabel>
<FormControl>
<Select
@@ -559,6 +660,25 @@ export function ExpenseForm({
</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>
</CollapsibleContent>
</Collapsible>
@@ -598,15 +718,7 @@ export function ExpenseForm({
{isCreate ? <>Create</> : <>Save</>}
</SubmitButton>
{!isCreate && onDelete && (
<AsyncButton
type="button"
variant="destructive"
loadingContent="Deleting…"
action={onDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</AsyncButton>
<DeletePopup onDelete={onDelete}></DeletePopup>
)}
<Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link>

View File

@@ -72,6 +72,7 @@ export async function createExpense(
})),
},
},
notes: expenseFormValues.notes,
},
})
}
@@ -185,6 +186,7 @@ export async function updateExpense(
id: doc.id,
})),
},
notes: expenseFormValues.notes,
},
})
}

View File

@@ -105,6 +105,7 @@ export const expenseFormSchema = z
Object.values(SplitMode) as any,
)
.default('EVENLY'),
saveDefaultSplittingOptions: z.boolean(),
isReimbursement: z.boolean(),
documents: z
.array(
@@ -116,6 +117,7 @@ export const expenseFormSchema = z
}),
)
.default([]),
notes: z.string().optional(),
})
.superRefine((expense, ctx) => {
let sum = 0
@@ -160,3 +162,9 @@ export const expenseFormSchema = z
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
export type SplittingOptions = {
// Used for saving default splitting options in localStorage
splitMode: SplitMode
paidFor: ExpenseFormValues['paidFor'] | null
}