From aa8c343ee0e709af4bcaab8944534cdbecf8f542 Mon Sep 17 00:00:00 2001 From: Philipp Eckel Date: Mon, 18 Aug 2025 08:36:48 +0200 Subject: [PATCH] feat: add expense duplication functionality - Add "Copy" and "Duplicate expense" translations in DE, EN, and FR - Implement duplicate expense feature in edit form with URL parameter - Add duplicate button to expense form with copy icon - Auto-prefix duplicated expense titles with "(Copy)" suffix - Set current date for duplicated expenses - Hide cancel button and duplicate button in duplicate mode - Navigate to edit page of new duplicated expense --- messages/de-DE.json | 2 + messages/en-US.json | 2 + messages/fr-FR.json | 2 + .../[groupId]/expenses/edit-expense-form.tsx | 19 ++++++++- .../[groupId]/expenses/expense-form.tsx | 42 ++++++++++++++++--- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 7557e8d4..c3929531 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -189,6 +189,8 @@ "notesField": { "label": "Notizen" }, + "copy": "Kopie", + "duplicate": "Ausgabe duplizieren", "selectNone": "Keine auswählen", "selectAll": "Alle auswählen", "shares": "Anteil(e)", diff --git a/messages/en-US.json b/messages/en-US.json index daaa5be9..753755db 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -190,6 +190,8 @@ "notesField": { "label": "Notes" }, + "copy": "Copy", + "duplicate": "Duplicate expense", "selectNone": "Select none", "selectAll": "Select all", "shares": "share(s)", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 20bcc34f..fe84179a 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -189,6 +189,8 @@ "notesField": { "label": "Notes" }, + "copy": "Copie", + "duplicate": "Dupliquer la dépense", "selectNone": "Tout désélectionner", "selectAll": "Tout sélectionner", "shares": "part(s)", diff --git a/src/app/groups/[groupId]/expenses/edit-expense-form.tsx b/src/app/groups/[groupId]/expenses/edit-expense-form.tsx index d762ec44..05e9c97a 100644 --- a/src/app/groups/[groupId]/expenses/edit-expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/edit-expense-form.tsx @@ -1,7 +1,7 @@ 'use client' import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { trpc } from '@/trpc/client' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { ExpenseForm } from './expense-form' export function EditExpenseForm({ @@ -25,10 +25,15 @@ export function EditExpenseForm({ }) const expense = expenseData?.expense + const searchParams = useSearchParams() + const isDuplicate = searchParams.get('duplicate') === 'true' + const { mutateAsync: updateExpenseMutateAsync } = trpc.groups.expenses.update.useMutation() const { mutateAsync: deleteExpenseMutateAsync } = trpc.groups.expenses.delete.useMutation() + const { mutateAsync: createExpenseMutateAsync } = + trpc.groups.expenses.create.useMutation() const utils = trpc.useUtils() const router = useRouter() @@ -40,6 +45,7 @@ export function EditExpenseForm({ group={group} expense={expense} categories={categories} + isDuplicate={isDuplicate} onSubmit={async (expenseFormValues, participantId) => { await updateExpenseMutateAsync({ expenseId, @@ -59,6 +65,17 @@ export function EditExpenseForm({ utils.groups.expenses.invalidate() router.push(`/groups/${group.id}`) }} + onDuplicate={async (expenseFormValues, participantId) => { + const newExpense = await createExpenseMutateAsync({ + groupId, + expenseFormValues, + participantId, + }) + utils.groups.expenses.invalidate() + router.push( + `/groups/${group.id}/expenses/${newExpense.expenseId}/edit?duplicate=true`, + ) + }} runtimeFeatureFlags={runtimeFeatureFlags} /> ) diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index b1919b25..825b1d78 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -45,7 +45,7 @@ import { cn } from '@/lib/utils' import { AppRouterOutput } from '@/trpc/routers/_app' import { zodResolver } from '@hookform/resolvers/zod' import { RecurrenceRule } from '@prisma/client' -import { Save } from 'lucide-react' +import { Copy, Save } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' import { useSearchParams } from 'next/navigation' @@ -145,14 +145,21 @@ export function ExpenseForm({ expense, onSubmit, onDelete, + onDuplicate, runtimeFeatureFlags, + isDuplicate = false, }: { group: NonNullable categories: AppRouterOutput['categories']['list']['categories'] expense?: AppRouterOutput['groups']['expenses']['get']['expense'] onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise onDelete?: (participantId?: string) => Promise + onDuplicate?: ( + value: ExpenseFormValues, + participantId?: string, + ) => Promise runtimeFeatureFlags: RuntimeFeatureFlags + isDuplicate?: boolean }) { const t = useTranslations('ExpenseForm') const isCreate = expense === undefined @@ -176,8 +183,12 @@ export function ExpenseForm({ resolver: zodResolver(expenseFormSchema), defaultValues: expense ? { - title: expense.title, - expenseDate: expense.expenseDate ?? new Date(), + title: isDuplicate + ? `${expense.title} (${t('copy')})` + : expense.title, + expenseDate: isDuplicate + ? new Date() + : expense.expenseDate ?? new Date(), amount: String(expense.amount / 100) as unknown as number, // hack category: expense.categoryId, paidBy: expense.paidById, @@ -253,6 +264,17 @@ export function ExpenseForm({ return onSubmit(values, activeUserId ?? undefined) } + const duplicate = async () => { + const values = form.getValues() + const duplicateValues = { + ...values, + expenseDate: new Date(), + } + if (onDuplicate) { + return onDuplicate(duplicateValues, activeUserId ?? undefined) + } + } + const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0) const [manuallyEditedParticipants, setManuallyEditedParticipants] = useState< Set @@ -893,14 +915,22 @@ export function ExpenseForm({ {t(isCreate ? 'create' : 'save')} + {!isCreate && !isDuplicate && onDuplicate && ( + + )} {!isCreate && onDelete && ( onDelete(activeUserId ?? undefined)} > )} - + {!isDuplicate && ( + + )}