Merge pull request #617 from mchev/duplicate_expense

Add duplicate expense action
This commit is contained in:
mchev
2026-04-08 09:28:22 +02:00
committed by GitHub
10 changed files with 382 additions and 4 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
use App\Http\Controllers\Controller;
use App\Http\Requests\DuplicateExpenseRequest;
use App\Http\Resources\ExpenseResource;
use App\Models\CompanySetting;
use App\Models\ExchangeRateLog;
use App\Models\Expense;
class DuplicateExpenseController extends Controller
{
/**
* Duplicate an expense, appending " (copy)" to the note (description).
*/
public function __invoke(DuplicateExpenseRequest $request, Expense $expense): ExpenseResource
{
$this->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);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DuplicateExpenseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'expense_date' => [
'required',
'date_format:Y-m-d',
],
];
}
}

View File

@@ -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",

View File

@@ -21,6 +21,18 @@
</BaseDropdownItem>
</router-link>
<!-- duplicate expense -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
@click="onDuplicateExpense(row)"
>
<BaseIcon
name="DocumentDuplicateIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('expenses.duplicate_expense') }}
</BaseDropdownItem>
<!-- delete expense -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.DELETE_EXPENSE)"
@@ -37,10 +49,10 @@
<script setup>
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({

View File

@@ -0,0 +1,146 @@
<template>
<BaseModal
:show="modalActive"
:initial-focus="initialFocusRef"
@close="closeModal"
@open="onModalOpen"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-gray-500 cursor-pointer"
@click="closeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitDuplicate">
<div
ref="initialFocusRef"
class="sr-only outline-none focus:outline-none"
tabindex="-1"
aria-hidden="true"
/>
<div class="px-8 py-6 sm:p-6">
<p class="mb-6 text-sm text-gray-600">
{{ $t('expenses.duplicate_expense_modal_hint') }}
</p>
<BaseInputGroup
:label="$t('expenses.expense_date')"
variant="vertical"
required
>
<BaseDatePicker
v-model="selectedExpenseDate"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</div>
<div
class="
z-0
flex
justify-end
px-4
py-4
border-t border-gray-200 border-solid
"
>
<BaseButton
class="mr-2"
variant="primary-outline"
type="button"
@click="closeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isDuplicating"
:disabled="isDuplicating || !selectedExpenseDate"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isDuplicating"
name="DocumentDuplicateIcon"
:class="slotProps.class"
/>
</template>
{{ $t('expenses.duplicate_expense') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<script setup>
import { ref, computed } from 'vue'
import moment from 'moment'
import { useRouter } from 'vue-router'
import { useModalStore } from '@/scripts/stores/modal'
import { useExpenseStore } from '@/scripts/admin/stores/expense'
const modalStore = useModalStore()
const expenseStore = useExpenseStore()
const router = useRouter()
const selectedExpenseDate = ref('')
const isDuplicating = ref(false)
const initialFocusRef = ref(null)
const modalActive = computed(
() => modalStore.active && modalStore.componentName === 'DuplicateExpenseModal'
)
function toYmd(value) {
if (!value) {
return moment().format('YYYY-MM-DD')
}
const str = String(value)
if (str.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(str)) {
return str.slice(0, 10)
}
return moment(value).format('YYYY-MM-DD')
}
function onModalOpen() {
selectedExpenseDate.value = toYmd(modalStore.data?.expense_date)
}
async function submitDuplicate() {
if (!modalStore.data?.id || !selectedExpenseDate.value) {
return
}
isDuplicating.value = true
try {
await expenseStore.duplicateExpense({
id: modalStore.data.id,
expense_date: selectedExpenseDate.value,
})
modalStore.refreshData && modalStore.refreshData()
closeModal()
router.push('/admin/expenses')
} finally {
isDuplicating.value = false
}
}
function closeModal() {
modalStore.closeModal()
selectedExpenseDate.value = ''
}
</script>

View File

@@ -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)

View File

@@ -1,5 +1,7 @@
<template>
<BasePage>
<DuplicateExpenseModal />
<!-- Page Header -->
<BasePageHeader :title="$t('expenses.title')">
<BaseBreadcrumb>
@@ -231,6 +233,7 @@ import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import UFOIcon from '@/scripts/components/icons/empty/UFOIcon.vue'
import DuplicateExpenseModal from '@/scripts/admin/components/modal-components/DuplicateExpenseModal.vue'
import ExpenseDropdown from '@/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue'
const companyStore = useCompanyStore()

View File

@@ -6,6 +6,7 @@
static
class="fixed inset-0 z-20 overflow-y-auto"
:open="show"
:initial-focus="initialFocus"
@close="$emit('close')"
>
<div
@@ -106,6 +107,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
/**
* Optional ref (template ref) for the element that should receive focus when the dialog opens.
* When omitted, Headless UI focuses the first focusable control (often an input).
*/
initialFocus: {
type: Object,
default: null,
},
})
const slots = useSlots()

View File

@@ -23,6 +23,7 @@ use App\Http\Controllers\V1\Admin\ExchangeRate\GetActiveProviderController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetExchangeRateController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetSupportedCurrenciesController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetUsedCurrenciesController;
use App\Http\Controllers\V1\Admin\Expense\DuplicateExpenseController;
use App\Http\Controllers\V1\Admin\Expense\ExpenseCategoriesController;
use App\Http\Controllers\V1\Admin\Expense\ExpensesController;
use App\Http\Controllers\V1\Admin\Expense\ShowReceiptController;
@@ -312,6 +313,8 @@ Route::prefix('/v1')->group(function () {
Route::post('/expenses/delete', [ExpensesController::class, 'delete']);
Route::post('/expenses/{expense}/duplicate', DuplicateExpenseController::class);
Route::apiResource('expenses', ExpensesController::class);
Route::apiResource('categories', ExpenseCategoriesController::class);

View File

@@ -1,6 +1,8 @@
<?php
use App\Http\Controllers\V1\Admin\Expense\DuplicateExpenseController;
use App\Http\Controllers\V1\Admin\Expense\ExpensesController;
use App\Http\Requests\DuplicateExpenseRequest;
use App\Http\Requests\ExpenseRequest;
use App\Models\Expense;
use App\Models\User;
@@ -114,6 +116,79 @@ test('search expenses', function () {
$response->assertOk();
});
test('duplicate expense', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
'notes' => 'Monthly rent',
]);
$response = postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2019-02-05',
]);
$response->assertStatus(201);
$newId = $response->json('data.id');
expect($newId)->not->toBe($expense->id);
$this->assertDatabaseHas('expenses', [
'id' => $newId,
'expense_date' => '2019-02-05',
'notes' => 'Monthly rent (copy)',
'expense_category_id' => $expense->expense_category_id,
'amount' => $expense->amount,
]);
});
test('duplicate expense with empty note uses copy as note', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
'notes' => null,
]);
postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2019-02-05',
])
->assertStatus(201)
->assertJsonPath('data.notes', '(copy)');
});
test('duplicate expense uses submitted expense date', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
]);
$response = postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2024-03-10',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('expenses', [
'id' => $response->json('data.id'),
'expense_date' => '2024-03-10',
]);
});
test('duplicate expense requires expense date', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
]);
postJson("api/v1/expenses/{$expense->id}/duplicate", [])
->assertUnprocessable()
->assertJsonValidationErrors(['expense_date']);
});
test('duplicate validates using a form request', function () {
$this->assertActionUsesFormRequest(
DuplicateExpenseController::class,
'__invoke',
DuplicateExpenseRequest::class
);
});
test('delete multiple expenses', function () {
$expenses = Expense::factory()->count(3)->create([
'expense_date' => '2019-02-05',