mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-11 15:34:50 +00:00
Merge pull request #617 from mchev/duplicate_expense
Add duplicate expense action
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/DuplicateExpenseRequest.php
Normal file
31
app/Http/Requests/DuplicateExpenseRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
21
resources/scripts/admin/stores/expense.js
vendored
21
resources/scripts/admin/stores/expense.js
vendored
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user