From beb2a43ed3566150c1a12462b10ba6819df0b7cd Mon Sep 17 00:00:00 2001 From: mchev Date: Tue, 7 Apr 2026 19:00:07 +0200 Subject: [PATCH] Duplicate expense --- .../Expense/DuplicateExpenseController.php | 65 ++++++++ app/Http/Requests/DuplicateExpenseRequest.php | 31 ++++ lang/en.json | 4 + .../dropdowns/ExpenseIndexDropdown.vue | 29 +++- .../DuplicateExpenseModal.vue | 146 ++++++++++++++++++ resources/scripts/admin/stores/expense.js | 21 +++ .../scripts/admin/views/expenses/Index.vue | 3 + .../scripts/components/base/BaseModal.vue | 9 ++ routes/api.php | 3 + tests/Feature/Admin/ExpenseTest.php | 75 +++++++++ 10 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/V1/Admin/Expense/DuplicateExpenseController.php create mode 100644 app/Http/Requests/DuplicateExpenseRequest.php create mode 100644 resources/scripts/admin/components/modal-components/DuplicateExpenseModal.vue diff --git a/app/Http/Controllers/V1/Admin/Expense/DuplicateExpenseController.php b/app/Http/Controllers/V1/Admin/Expense/DuplicateExpenseController.php new file mode 100644 index 00000000..8d124631 --- /dev/null +++ b/app/Http/Controllers/V1/Admin/Expense/DuplicateExpenseController.php @@ -0,0 +1,65 @@ +authorize('view', $expense); + $this->authorize('create', Expense::class); + + $expense->load('fields'); + + $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $currentCurrency = $expense->currency_id; + $exchangeRate = $companyCurrency != $currentCurrency ? $expense->exchange_rate : 1; + + $notes = trim((string) $expense->notes); + $duplicatedNotes = $notes === '' ? '(copy)' : $notes.' (copy)'; + + $newExpense = Expense::query()->create([ + 'expense_date' => $request->validated('expense_date'), + 'expense_number' => null, + 'expense_category_id' => $expense->expense_category_id, + 'payment_method_id' => $expense->payment_method_id, + 'amount' => $expense->amount, + 'customer_id' => $expense->customer_id, + 'notes' => $duplicatedNotes, + 'currency_id' => $expense->currency_id, + 'creator_id' => $request->user()->id, + 'company_id' => $request->header('company'), + 'exchange_rate' => $exchangeRate, + 'base_amount' => $expense->amount * $exchangeRate, + ]); + + if ((string) $newExpense->currency_id !== (string) $companyCurrency) { + ExchangeRateLog::addExchangeRateLog($newExpense); + } + + if ($expense->fields()->exists()) { + $customFields = []; + + foreach ($expense->fields as $data) { + $customFields[] = [ + 'id' => $data->custom_field_id, + 'value' => $data->defaultAnswer, + ]; + } + + $newExpense->addCustomFields($customFields); + } + + return new ExpenseResource($newExpense); + } +} diff --git a/app/Http/Requests/DuplicateExpenseRequest.php b/app/Http/Requests/DuplicateExpenseRequest.php new file mode 100644 index 00000000..f3777c58 --- /dev/null +++ b/app/Http/Requests/DuplicateExpenseRequest.php @@ -0,0 +1,31 @@ +> + */ + public function rules(): array + { + return [ + 'expense_date' => [ + 'required', + 'date_format:Y-m-d', + ], + ]; + } +} diff --git a/lang/en.json b/lang/en.json index 81e852c1..8202ba52 100644 --- a/lang/en.json +++ b/lang/en.json @@ -677,6 +677,10 @@ "no_expenses": "No expenses yet!", "list_of_expenses": "This section will contain the list of expenses.", "confirm_delete": "You will not be able to recover this Expense | You will not be able to recover these Expenses", + "duplicate_expense": "Duplicate", + "duplicate_expense_title": "Duplicate expense", + "duplicate_expense_modal_hint": "Change the date if you need to. (copy) is added to the note.", + "duplicated_message": "Expense duplicated successfully", "created_message": "Expense created successfully", "updated_message": "Expense updated successfully", "deleted_message": "Expense deleted successfully | Expenses deleted successfully", diff --git a/resources/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue b/resources/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue index 74ea564a..6bdf126b 100644 --- a/resources/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue +++ b/resources/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue @@ -21,6 +21,18 @@ + + + + {{ $t('expenses.duplicate_expense') }} + + import { useDialogStore } from '@/scripts/stores/dialog' -import { useNotificationStore } from '@/scripts/stores/notification' +import { useModalStore } from '@/scripts/stores/modal' import { useI18n } from 'vue-i18n' import { useExpenseStore } from '@/scripts/admin/stores/expense' -import { useRoute, useRouter } from 'vue-router' +import { useRoute } from 'vue-router' import { inject } from 'vue' import { useUserStore } from '@/scripts/admin/stores/user' import abilities from '@/scripts/admin/stub/abilities' @@ -61,15 +73,24 @@ const props = defineProps({ }) const dialogStore = useDialogStore() -const notificationStore = useNotificationStore() +const modalStore = useModalStore() const { t } = useI18n() const expenseStore = useExpenseStore() const route = useRoute() -const router = useRouter() const userStore = useUserStore() const $utils = inject('utils') +function onDuplicateExpense(row) { + modalStore.openModal({ + title: t('expenses.duplicate_expense_title'), + componentName: 'DuplicateExpenseModal', + data: row, + size: 'sm', + refreshData: props.loadData, + }) +} + function removeExpense(id) { dialogStore .openDialog({ diff --git a/resources/scripts/admin/components/modal-components/DuplicateExpenseModal.vue b/resources/scripts/admin/components/modal-components/DuplicateExpenseModal.vue new file mode 100644 index 00000000..ec37811f --- /dev/null +++ b/resources/scripts/admin/components/modal-components/DuplicateExpenseModal.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/scripts/admin/stores/expense.js b/resources/scripts/admin/stores/expense.js index 024e5459..dba84cdd 100644 --- a/resources/scripts/admin/stores/expense.js +++ b/resources/scripts/admin/stores/expense.js @@ -91,6 +91,27 @@ export const useExpenseStore = (useWindow = false) => { }) }, + duplicateExpense({ id, expense_date }) { + return new Promise((resolve, reject) => { + http + .post(`/api/v1/expenses/${id}/duplicate`, { expense_date }) + .then((response) => { + const notificationStore = useNotificationStore() + + notificationStore.showNotification({ + type: 'success', + message: global.t('expenses.duplicated_message'), + }) + + resolve(response) + }) + .catch((err) => { + handleError(err) + reject(err) + }) + }) + }, + addExpense(data) { const formData = utils.toFormData(data) diff --git a/resources/scripts/admin/views/expenses/Index.vue b/resources/scripts/admin/views/expenses/Index.vue index 2dc1b4f1..0f080ed9 100644 --- a/resources/scripts/admin/views/expenses/Index.vue +++ b/resources/scripts/admin/views/expenses/Index.vue @@ -1,5 +1,7 @@