Clone estimates (#97)

* Clone estimates

* Clone estimate test feature

* Resolve namespace

* Fix string to int for Carbon

* Fix homes routes and default queue key

* Move dropdown item below View and use the propper translation key
This commit is contained in:
mchev
2024-06-06 12:16:41 +02:00
committed by GitHub
parent 14c599ed4f
commit bb8258036a
25 changed files with 262 additions and 2 deletions

View File

@@ -23,7 +23,7 @@ DB_PASSWORD="invoiceshelf"
BROADCAST_CONNECTION=log
CACHE_STORE=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
SESSION_DRIVER=cookie
SESSION_LIFETIME=1440
SESSION_ENCRYPT=false

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Resources\EstimateResource;
use App\Models\CompanySetting;
use App\Models\Estimate;
use App\Services\SerialNumberFormatter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Vinkla\Hashids\Facades\Hashids;
class CloneEstimateController extends Controller
{
/**
* Mail a specific invoice to the corresponding customer's email address.
*
* @return \Illuminate\Http\JsonResponse
*/
public function __invoke(Request $request, Estimate $estimate)
{
$this->authorize('create', Estimate::class);
$date = Carbon::now();
$serial = (new SerialNumberFormatter())
->setModel($estimate)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setNextNumbers();
$due_date = null;
$dueDateEnabled = CompanySetting::getSetting(
'estimate_set_expiry_date_automatically',
$request->header('company')
);
if ($dueDateEnabled === 'YES') {
$dueDateDays = intval(CompanySetting::getSetting(
'estimate_expiry_date_days',
$request->header('company')
));
$due_date = Carbon::now()->addDays($dueDateDays)->format('Y-m-d');
}
$exchange_rate = $estimate->exchange_rate;
$newEstimate = Estimate::create([
'estimate_date' => $date->format('Y-m-d'),
'expiry_date' => $due_date,
'estimate_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
'reference_number' => $estimate->reference_number,
'customer_id' => $estimate->customer_id,
'company_id' => $request->header('company'),
'template_name' => $estimate->template_name,
'status' => Estimate::STATUS_DRAFT,
'sub_total' => $estimate->sub_total,
'discount' => $estimate->discount,
'discount_type' => $estimate->discount_type,
'discount_val' => $estimate->discount_val,
'total' => $estimate->total,
'due_amount' => $estimate->total,
'tax_per_item' => $estimate->tax_per_item,
'discount_per_item' => $estimate->discount_per_item,
'tax' => $estimate->tax,
'notes' => $estimate->notes,
'exchange_rate' => $exchange_rate,
'base_total' => $estimate->total * $exchange_rate,
'base_discount_val' => $estimate->discount_val * $exchange_rate,
'base_sub_total' => $estimate->sub_total * $exchange_rate,
'base_tax' => $estimate->tax * $exchange_rate,
'base_due_amount' => $estimate->total * $exchange_rate,
'currency_id' => $estimate->currency_id,
'sales_tax_type' => $estimate->sales_tax_type,
'sales_tax_address_type' => $estimate->sales_tax_address_type,
]);
$newEstimate->unique_hash = Hashids::connection(Estimate::class)->encode($newEstimate->id);
$newEstimate->save();
$estimate->load('items.taxes');
$estimateItems = $estimate->items->toArray();
foreach ($estimateItems as $estimateItem) {
$estimateItem['company_id'] = $request->header('company');
$estimateItem['name'] = $estimateItem['name'];
$estimateItem['exchange_rate'] = $exchange_rate;
$estimateItem['base_price'] = $estimateItem['price'] * $exchange_rate;
$estimateItem['base_discount_val'] = $estimateItem['discount_val'] * $exchange_rate;
$estimateItem['base_tax'] = $estimateItem['tax'] * $exchange_rate;
$estimateItem['base_total'] = $estimateItem['total'] * $exchange_rate;
$item = $newEstimate->items()->create($estimateItem);
if (array_key_exists('taxes', $estimateItem) && $estimateItem['taxes']) {
foreach ($estimateItem['taxes'] as $tax) {
$tax['company_id'] = $request->header('company');
if ($tax['amount']) {
$item->taxes()->create($tax);
}
}
}
}
if ($estimate->taxes) {
foreach ($estimate->taxes->toArray() as $tax) {
$tax['company_id'] = $request->header('company');
$newEstimate->taxes()->create($tax);
}
}
if ($estimate->fields()->exists()) {
$customFields = [];
foreach ($estimate->fields as $data) {
$customFields[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
}
$newEstimate->addCustomFields($customFields);
}
return new EstimateResource($newEstimate);
}
}

View File

@@ -35,7 +35,16 @@ class AppServiceProvider extends ServiceProvider
*
* @var string
*/
public const HOME = '/home';
public const HOME = '/admin/dashboard';
/**
* The path to the "customer home" route for your application.
*
* This is used by Laravel authentication to redirect customers after login.
*
* @var string
*/
public const CUSTOMER_HOME = '/customer/dashboard';
/**
* Bootstrap any application services.

View File

@@ -304,6 +304,9 @@
"record_payment": "تسجيل مدفوات",
"add_estimate": "إضافة تقدير",
"save_estimate": "حفظ التقدير",
"cloned_successfully": "تم استنساخ العرض بنجاح",
"clone_estimate": "استنساخ العرض",
"confirm_clone": "سيتم استنساخ هذا العرض إلى عرض جديد",
"confirm_conversion": "هل تريد تحويل هذا التقدير إلى فاتورة؟",
"conversion_message": "تم إنشاء الفاتورة بنجاح",
"confirm_send_estimate": "سيتم إرسال هذا التقدير بالبريد الإلكتروني إلى العميل",

View File

@@ -304,6 +304,9 @@
"record_payment": "Zaznamenat platbu",
"add_estimate": "Přidat nabídku",
"save_estimate": "Uložit nabídku",
"cloned_successfully": "Devis úspěšně zkopírován",
"clone_estimate": "Klonovat devis",
"confirm_clone": "Tento devis bude zkopírován do nového devisu",
"confirm_conversion": "Tento odhad bude použit k vytvoření nové faktury.",
"conversion_message": "Faktura byla úspěšně vytvořena",
"confirm_send_estimate": "Tento odhad bude zaslán e-mailem zákazníkovi",

View File

@@ -304,6 +304,9 @@
"record_payment": "Zahlung erfassen",
"add_estimate": "Angebote hinzufügen",
"save_estimate": "Angebot speichern",
"cloned_successfully": "Angebot erfolgreich geklont",
"clone_estimate": "Angebot klonen",
"confirm_clone": "Dieses Angebot wird in ein neues Angebot kopiert",
"confirm_conversion": "Dieses Angebot wird verwendet, um eine neue Rechnung zu erstellen.",
"conversion_message": "Rechnung erfolgreich erstellt",
"confirm_send_estimate": "Das Angebot wird per E-Mail an den Kunden gesendet",

View File

@@ -304,6 +304,9 @@
"record_payment": "Καταγραφή Πληρωμής",
"add_estimate": "Νέα Εκτίμηση",
"save_estimate": "Νέα Εκτίμηση",
"cloned_successfully": "Η προσφορά κλωνοποιήθηκε με επιτυχία",
"clone_estimate": "Κλωνοποίηση προσφοράς",
"confirm_clone": "Αυτή η προσφορά θα κλωνοποιηθεί σε μια νέα προσφορά",
"confirm_conversion": "Αυτή η εκτίμηση θα χρησιμοποιηθεί για τη δημιουργία ενός νέου τιμολογίου.",
"conversion_message": "Το τιμολόγιο κλωνοποιήθηκε επιτυχώς",
"confirm_send_estimate": "Αυτό το τιμολόγιο θα αποσταλεί μέσω email στον πελάτη",

View File

@@ -323,6 +323,9 @@
"record_payment": "Record Payment",
"add_estimate": "Add Estimate",
"save_estimate": "Save Estimate",
"cloned_successfully": "Estimate cloned successfully",
"clone_estimate": "Clone Estimate",
"confirm_clone": "This Estimate will be cloned into a new Estimate",
"confirm_conversion": "This estimate will be used to create a new Invoice.",
"conversion_message": "Invoice created successful",
"confirm_send_estimate": "This estimate will be sent via email to the customer",

View File

@@ -304,6 +304,9 @@
"record_payment": "Registro de pago",
"add_estimate": "Agregar presupuesto",
"save_estimate": "Guardar presupuesto",
"cloned_successfully": "Presupuesto clonado con éxito",
"clone_estimate": "Clonar presupuesto",
"confirm_clone": "Este presupuesto será clonado en un nuevo presupuesto",
"confirm_conversion": "¿Quiere convertir este presupuesto en una factura?",
"conversion_message": "Conversión exitosa",
"confirm_send_estimate": "Este presupuesto se enviará por correo electrónico al cliente",

View File

@@ -323,6 +323,9 @@
"record_payment": "Enregistrer un paiement",
"add_estimate": "Nouveau devis",
"save_estimate": "Enregistrer",
"cloned_successfully": "Devis dupliqué avec succès",
"clone_estimate": "Dupliquer le devis",
"confirm_clone": "Ce devis sera dupliqué dans un nouveau devis",
"confirm_conversion": "Ce devis sera utilisé pour créer une nouvelle facture.",
"conversion_message": "Conversion réussie",
"confirm_send_estimate": "Ce devis sera envoyée par email au client",

View File

@@ -304,6 +304,9 @@
"record_payment": "Registra Pagamento",
"add_estimate": "Aggiungi Preventivo",
"save_estimate": "Salva Preventivo",
"cloned_successfully": "Preventivo clonato con successo",
"clone_estimate": "Clonare preventivo",
"confirm_clone": "Questo preventivo sarà clonato in un nuovo preventivo",
"confirm_conversion": "Questo preventivo verrà usato per generare una nuova fattura.",
"conversion_message": "Fattura creata",
"confirm_send_estimate": "Questo preventivo verrà inviato al cliente via mail",

View File

@@ -304,6 +304,9 @@
"record_payment": "Record Payment",
"add_estimate": "Add Estimate",
"save_estimate": "Save Estimate",
"cloned_successfully": "見積もりが正常にクローンされました",
"clone_estimate": "見積もりをクローン",
"confirm_clone": "この見積もりは新しい見積もりにクローンされます",
"confirm_conversion": "This estimate will be used to create a new Invoice.",
"conversion_message": "Invoice created successful",
"confirm_send_estimate": "This estimate will be sent via email to the customer",

View File

@@ -258,6 +258,9 @@
"record_payment": "기록 지불",
"add_estimate": "견적 추가",
"save_estimate": "견적 저장",
"cloned_successfully": "견적이 성공적으로 복제되었습니다",
"clone_estimate": "견적 복제",
"confirm_clone": "이 견적은 새로운 견적으로 복제될 것입니다",
"confirm_conversion": "이 견적은 새 인보이스를 만드는 데 사용됩니다.",
"conversion_message": "인보이스가 성공적으로 생성되었습니다.",
"confirm_send_estimate": "이 견적은 이메일을 통해 고객에게 전송됩니다.",

View File

@@ -304,6 +304,9 @@
"record_payment": "Izveidot maksājumu",
"add_estimate": "Pievienot aprēķinu",
"save_estimate": "Saglabāt aprēķinu",
"cloned_successfully": "Piedāvājums veiksmīgi klonēts",
"clone_estimate": "Klonēt piedāvājumu",
"confirm_clone": "Šis piedāvājums tiks klonēts jaunā piedāvājumā",
"confirm_conversion": "Šis aprēķins tiks izmantots, lai izveidotu jaunu rēķinu.",
"conversion_message": "Rēķins izveidots veiksmīgi",
"confirm_send_estimate": "Šis aprēķins tiks nosūtīts klientam e-pastā",

View File

@@ -304,6 +304,9 @@
"record_payment": "Betaling registreren",
"add_estimate": "Offerte toevoegen",
"save_estimate": "Bewaar offerte",
"cloned_successfully": "Offerte succesvol gekloond",
"clone_estimate": "Offerte klonen",
"confirm_clone": "Deze offerte zal worden gekopieerd naar een nieuwe offerte",
"confirm_conversion": "Deze offerte wordt gebruikt om een nieuwe factuur te maken.",
"conversion_message": "Factuur gemaakt",
"confirm_send_estimate": "Deze offerte wordt via e-mail naar de klant gestuurd",

View File

@@ -304,6 +304,9 @@
"record_payment": "Zarejestruj płatność",
"add_estimate": "Dodaj ofertę",
"save_estimate": "Zapisz ofertę",
"cloned_successfully": "Oferta została pomyślnie sklonowana",
"clone_estimate": "Sklonuj ofertę",
"confirm_clone": "Ta oferta zostanie sklonowana do nowej oferty",
"confirm_conversion": "Ta oferta zostanie użyta do utworzenia nowej faktury.",
"conversion_message": "Faktura została utworzona pomyślnie",
"confirm_send_estimate": "Ta oferta zostanie wysłana pocztą elektroniczną do kontrahenta",

View File

@@ -237,6 +237,9 @@
"record_payment": "Registro de pago",
"add_estimate": "Adicionar orçamento",
"save_estimate": "Salvar Orçamento",
"cloned_successfully": "Orçamento clonado com sucesso",
"clone_estimate": "Clonar orçamento",
"confirm_clone": "Este orçamento será clonado em um novo orçamento",
"confirm_conversion": "Deseja converter este orçamento em uma fatura?",
"conversion_message": "Converção realizada com sucesso",
"confirm_send_estimate": "Este orçamento será enviado por email ao cliente",

View File

@@ -304,6 +304,9 @@
"record_payment": "Zaznamenať Platbu",
"add_estimate": "Vytvoriť Cenový odhad",
"save_estimate": "Uložiť Cenový odhad",
"cloned_successfully": "Ponuka úspešne skopírovaná",
"clone_estimate": "Klonovať ponuku",
"confirm_clone": "Táto ponuka bude skopírovaná do novej ponuky",
"confirm_conversion": "Tento cenový odhad bude použitý k vytvoreniu novej Faktúry.",
"conversion_message": "Faktúra úspešne vytvorená",
"confirm_send_estimate": "Tento Cenový odhad bude odoslaný zákazníkovi prostredníctvom e-mailu",

View File

@@ -304,6 +304,9 @@
"record_payment": "Unesi uplatu",
"add_estimate": "Dodaj Profakturu",
"save_estimate": "Sačuvaj Profakturu",
"cloned_successfully": "Ponuda uspešno klonirana",
"clone_estimate": "Kloniraj ponudu",
"confirm_clone": "Ova ponuda će biti klonirana u novu ponudu",
"confirm_conversion": "Detalji ove Profakture će biti iskorišćeni za pravljenje Fakture.",
"conversion_message": "Faktura uspešno kreirana",
"confirm_send_estimate": "Ova Profaktura će biti poslata putem Email-a klijentu",

View File

@@ -304,6 +304,9 @@
"record_payment": "Registrera betalning",
"add_estimate": "Lägg till kostnadsförslag",
"save_estimate": "Spara kostnadsförslag",
"cloned_successfully": "Offert klonades framgångsrikt",
"clone_estimate": "Klona offert",
"confirm_clone": "Denna offert kommer att klonas till en ny offert",
"confirm_conversion": "Detta kostnadsförslag används för att skapa ny faktura.",
"conversion_message": "Faktura skapades",
"confirm_send_estimate": "Detta kostnadsförslag skickas via epost till kund",

View File

@@ -303,6 +303,9 @@
"record_payment": "บันทึกการชำระเงิน",
"add_estimate": "เพิ่มค่าใบเสนอราคา",
"save_estimate": "บันทึกใบเสนอราคา",
"cloned_successfully": "โคลนข้อเสนอสำเร็จ",
"clone_estimate": "โคลนข้อเสนอ",
"confirm_clone": "ข้อเสนอนี้จะถูกโคลนเป็นข้อเสนอใหม่",
"confirm_conversion": "ใบเสนอราคานี้จะใช้ในการสร้างใบวางบิลใหม่",
"conversion_message": "ใบวางบิลที่สร้างเสร็จสมบูรณ์",
"confirm_send_estimate": "ใบเสนอราคานี้จะถูกส่งผ่านทางอีเมลถึงลูกค้า",

View File

@@ -62,6 +62,18 @@
</BaseDropdownItem>
</router-link>
<!-- Clone Estimate into new estimate -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.CREATE_ESTIMATE)"
@click="cloneEstimateData(row)"
>
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('estimates.clone_estimate') }}
</BaseDropdownItem>
<!-- Convert into Invoice -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.CREATE_INVOICE)"
@@ -334,4 +346,24 @@ function copyPdfUrl() {
message: t('general.copied_pdf_url_clipboard'),
})
}
async function cloneEstimateData(data) {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_clone'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'primary',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
estimateStore.cloneEstimate(data).then((res) => {
router.push(`/admin/estimates/${res.data.data.id}/edit`)
})
}
})
}
</script>

View File

@@ -329,6 +329,25 @@ export const useEstimateStore = (useWindow = false) => {
})
},
cloneEstimate(data) {
return new Promise((resolve, reject) => {
axios
.post(`/api/v1/estimates/${data.id}/clone`, data)
.then((response) => {
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('estimates.cloned_successfully'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
markAsAccepted(data) {
return new Promise((resolve, reject) => {
axios

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\V1\Admin\Customer\CustomerStatsController;
use App\Http\Controllers\V1\Admin\CustomField\CustomFieldsController;
use App\Http\Controllers\V1\Admin\Dashboard\DashboardController;
use App\Http\Controllers\V1\Admin\Estimate\ChangeEstimateStatusController;
use App\Http\Controllers\V1\Admin\Estimate\CloneEstimateController;
use App\Http\Controllers\V1\Admin\Estimate\ConvertEstimateController;
use App\Http\Controllers\V1\Admin\Estimate\EstimatesController;
use App\Http\Controllers\V1\Admin\Estimate\EstimateTemplatesController;
@@ -285,6 +286,8 @@ Route::prefix('/v1')->group(function () {
Route::post('/estimates/{estimate}/send', SendEstimateController::class);
Route::post('/estimates/{estimate}/clone', CloneEstimateController::class);
Route::post('/estimates/{estimate}/status', ChangeEstimateStatusController::class);
Route::post('/estimates/{estimate}/convert-to-invoice', ConvertEstimateController::class);

View File

@@ -65,6 +65,18 @@ test('create estimate', function () {
]);
});
test('clone estimate', function () {
$estimate = Estimate::factory()->create();
$beforeCount = Estimate::count();
$response = $this->post("/api/v1/estimates/{$estimate->id}/clone");
$this->assertDatabaseCount('estimates', $beforeCount + 1);
});
test('store validates using a form request', function () {
$this->assertActionUsesFormRequest(
EstimatesController::class,