quotes: Generer des factures en acceptant un devis
This commit is contained in:
parent
e0ccd5f627
commit
503fb0d008
178
thanasoft-back/app/Http/Controllers/Api/InvoiceController.php
Normal file
178
thanasoft-back/app/Http/Controllers/Api/InvoiceController.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreInvoiceRequest;
|
||||
use App\Http\Requests\UpdateInvoiceRequest;
|
||||
use App\Http\Resources\InvoiceResource;
|
||||
use App\Repositories\InvoiceRepositoryInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InvoiceRepositoryInterface $invoiceRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of invoices.
|
||||
*/
|
||||
public function index(): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoices = $this->invoiceRepository->all();
|
||||
return InvoiceResource::collection($invoices);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching invoices: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération des factures.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created invoice.
|
||||
*/
|
||||
public function store(StoreInvoiceRequest $request): InvoiceResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoice = $this->invoiceRepository->create($request->validated());
|
||||
return new InvoiceResource($invoice);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating invoice: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'data' => $request->validated(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la création de la facture.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified invoice.
|
||||
*/
|
||||
public function show(string $id): InvoiceResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
|
||||
if (! $invoice) {
|
||||
return response()->json([
|
||||
'message' => 'Facture non trouvée.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return new InvoiceResource($invoice);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching invoice: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'invoice_id' => $id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération de la facture.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified invoice.
|
||||
*/
|
||||
public function update(UpdateInvoiceRequest $request, string $id): InvoiceResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$updated = $this->invoiceRepository->update($id, $request->validated());
|
||||
|
||||
if (! $updated) {
|
||||
return response()->json([
|
||||
'message' => 'Facture non trouvée ou échec de la mise à jour.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
return new InvoiceResource($invoice);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating invoice: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'invoice_id' => $id,
|
||||
'data' => $request->validated(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la mise à jour de la facture.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified invoice.
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$deleted = $this->invoiceRepository->delete($id);
|
||||
|
||||
if (! $deleted) {
|
||||
return response()->json([
|
||||
'message' => 'Facture non trouvée ou échec de la suppression.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Facture supprimée avec succès.',
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting invoice: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'invoice_id' => $id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la suppression de la facture.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invoice from a quote.
|
||||
*/
|
||||
public function createFromQuote(string $quoteId): InvoiceResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoice = $this->invoiceRepository->createFromQuote($quoteId);
|
||||
return new InvoiceResource($invoice);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating invoice from quote: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'quote_id' => $quoteId,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la création de la facture depuis le devis.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php
Normal file
78
thanasoft-back/app/Http/Requests/StoreInvoiceRequest.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInvoiceRequest 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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => 'required|exists:clients,id',
|
||||
'group_id' => 'nullable|exists:client_groups,id',
|
||||
'source_quote_id' => 'nullable|exists:quotes,id',
|
||||
'status' => 'required|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir',
|
||||
'invoice_date' => 'required|date',
|
||||
'due_date' => 'nullable|date|after_or_equal:invoice_date',
|
||||
'currency' => 'required|string|size:3',
|
||||
'total_ht' => 'required|numeric|min:0',
|
||||
'total_tva' => 'required|numeric|min:0',
|
||||
'total_ttc' => 'required|numeric|min:0',
|
||||
'e_invoicing_channel_id' => 'nullable|exists:e_invoicing_channels,id',
|
||||
'e_invoice_status' => 'nullable|in:cree,transmis,accepte,refuse,en_litige,acquitte,archive',
|
||||
'lines' => 'required|array|min:1',
|
||||
'lines.*.product_id' => 'nullable|exists:products,id',
|
||||
'lines.*.packaging_id' => 'nullable|exists:product_packagings,id',
|
||||
'lines.*.packages_qty' => 'nullable|numeric|min:0',
|
||||
'lines.*.units_qty' => 'nullable|numeric|min:0',
|
||||
'lines.*.description' => 'required|string',
|
||||
'lines.*.qty_base' => 'nullable|numeric|min:0',
|
||||
'lines.*.unit_price' => 'required|numeric|min:0',
|
||||
'lines.*.unit_price_per_package' => 'nullable|numeric|min:0',
|
||||
'lines.*.tva_rate_id' => 'nullable|exists:tva_rates,id',
|
||||
'lines.*.discount_pct' => 'required|numeric|min:0|max:100',
|
||||
'lines.*.total_ht' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'client_id.required' => 'Le client est obligatoire.',
|
||||
'client_id.exists' => 'Le client sélectionné est invalide.',
|
||||
'group_id.exists' => 'Le groupe sélectionné est invalide.',
|
||||
'status.required' => 'Le statut est obligatoire.',
|
||||
'status.in' => 'Le statut sélectionné est invalide.',
|
||||
'invoice_date.required' => 'La date de la facture est obligatoire.',
|
||||
'invoice_date.date' => 'La date de la facture n\'est pas valide.',
|
||||
'due_date.date' => 'La date d\'échéance n\'est pas valide.',
|
||||
'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.',
|
||||
'currency.required' => 'La devise est obligatoire.',
|
||||
'currency.size' => 'La devise doit comporter 3 caractères.',
|
||||
'total_ht.required' => 'Le total HT est obligatoire.',
|
||||
'total_ht.numeric' => 'Le total HT doit être un nombre.',
|
||||
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
|
||||
'total_tva.required' => 'Le total TVA est obligatoire.',
|
||||
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
|
||||
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
|
||||
'total_ttc.required' => 'Le total TTC est obligatoire.',
|
||||
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
|
||||
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
|
||||
'lines.required' => 'Veuillez ajouter au moins une ligne à la facture.',
|
||||
];
|
||||
}
|
||||
}
|
||||
67
thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php
Normal file
67
thanasoft-back/app/Http/Requests/UpdateInvoiceRequest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateInvoiceRequest 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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$invoiceId = $this->route('invoice');
|
||||
|
||||
return [
|
||||
'client_id' => 'sometimes|exists:clients,id',
|
||||
'group_id' => 'nullable|exists:client_groups,id',
|
||||
'source_quote_id' => 'nullable|exists:quotes,id',
|
||||
'invoice_number' => 'sometimes|string|max:191|unique:invoices,invoice_number,' . $invoiceId,
|
||||
'status' => 'sometimes|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir',
|
||||
'invoice_date' => 'sometimes|date',
|
||||
'due_date' => 'nullable|date|after_or_equal:invoice_date',
|
||||
'currency' => 'sometimes|string|size:3',
|
||||
'total_ht' => 'sometimes|numeric|min:0',
|
||||
'total_tva' => 'sometimes|numeric|min:0',
|
||||
'total_ttc' => 'sometimes|numeric|min:0',
|
||||
'e_invoicing_channel_id' => 'nullable|exists:e_invoicing_channels,id',
|
||||
'e_invoice_status' => 'nullable|in:cree,transmis,accepte,refuse,en_litige,acquitte,archive',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'client_id.exists' => 'Le client sélectionné est invalide.',
|
||||
'group_id.exists' => 'Le groupe sélectionné est invalide.',
|
||||
'source_quote_id.exists' => 'Le devis source est invalide.',
|
||||
'invoice_number.string' => 'Le numéro de facture doit être une chaîne de caractères.',
|
||||
'invoice_number.max' => 'Le numéro de facture ne doit pas dépasser 191 caractères.',
|
||||
'invoice_number.unique' => 'Ce numéro de facture existe déjà.',
|
||||
'status.in' => 'Le statut sélectionné est invalide.',
|
||||
'invoice_date.date' => 'La date de la facture n\'est pas valide.',
|
||||
'due_date.date' => 'La date d\'échéance n\'est pas valide.',
|
||||
'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.',
|
||||
'currency.size' => 'La devise doit comporter 3 caractères.',
|
||||
'total_ht.numeric' => 'Le total HT doit être un nombre.',
|
||||
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
|
||||
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
|
||||
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
|
||||
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
|
||||
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
|
||||
'e_invoicing_channel_id.exists' => 'Le canal de facturation électronique est invalide.',
|
||||
'e_invoice_status.in' => 'Le statut de facturation électronique est invalide.',
|
||||
];
|
||||
}
|
||||
}
|
||||
37
thanasoft-back/app/Http/Resources/InvoiceLineResource.php
Normal file
37
thanasoft-back/app/Http/Resources/InvoiceLineResource.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InvoiceLineResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'invoice_id' => $this->invoice_id,
|
||||
'product_id' => $this->product_id,
|
||||
'product_name' => $this->product ? $this->product->nom : null,
|
||||
'packaging_id' => $this->packaging_id,
|
||||
'packages_qty' => $this->packages_qty,
|
||||
'units_qty' => $this->units_qty,
|
||||
'description' => $this->description,
|
||||
'qty_base' => $this->qty_base,
|
||||
'unit_price' => $this->unit_price,
|
||||
'unit_price_per_package' => $this->unit_price_per_package,
|
||||
'tva_rate_id' => $this->tva_rate_id,
|
||||
'discount_pct' => $this->discount_pct,
|
||||
'total_ht' => $this->total_ht,
|
||||
'product' => $this->whenLoaded('product'),
|
||||
'packaging' => $this->whenLoaded('packaging'),
|
||||
'tva_rate' => $this->whenLoaded('tvaRate'),
|
||||
];
|
||||
}
|
||||
}
|
||||
41
thanasoft-back/app/Http/Resources/InvoiceResource.php
Normal file
41
thanasoft-back/app/Http/Resources/InvoiceResource.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InvoiceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'client_id' => $this->client_id,
|
||||
'group_id' => $this->group_id,
|
||||
'source_quote_id' => $this->source_quote_id,
|
||||
'invoice_number' => $this->invoice_number,
|
||||
'status' => $this->status,
|
||||
'invoice_date' => $this->invoice_date,
|
||||
'due_date' => $this->due_date,
|
||||
'currency' => $this->currency,
|
||||
'total_ht' => $this->total_ht,
|
||||
'total_tva' => $this->total_tva,
|
||||
'total_ttc' => $this->total_ttc,
|
||||
'e_invoicing_channel_id' => $this->e_invoicing_channel_id,
|
||||
'e_invoice_status' => $this->e_invoice_status,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'client' => $this->whenLoaded('client'),
|
||||
'group' => $this->whenLoaded('group'),
|
||||
'sourceQuote' => $this->whenLoaded('sourceQuote'),
|
||||
'lines' => InvoiceLineResource::collection($this->whenLoaded('lines')),
|
||||
'history' => DocumentStatusHistoryResource::collection($this->whenLoaded('history')),
|
||||
];
|
||||
}
|
||||
}
|
||||
89
thanasoft-back/app/Models/Invoice.php
Normal file
89
thanasoft-back/app/Models/Invoice.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'group_id',
|
||||
'source_quote_id',
|
||||
'invoice_number',
|
||||
'status',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'currency',
|
||||
'total_ht',
|
||||
'total_tva',
|
||||
'total_ttc',
|
||||
'e_invoicing_channel_id',
|
||||
'e_invoice_status',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($invoice) {
|
||||
// Auto-generate invoice number if not provided
|
||||
if (empty($invoice->invoice_number)) {
|
||||
$prefix = 'FAC-' . now()->format('Ym') . '-';
|
||||
$lastInvoice = self::where('invoice_number', 'like', $prefix . '%')
|
||||
->orderBy('invoice_number', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastInvoice) {
|
||||
$lastNumber = intval(substr($lastInvoice->invoice_number, -4));
|
||||
$newNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$newNumber = 1;
|
||||
}
|
||||
|
||||
$invoice->invoice_number = $prefix . str_pad((string)$newNumber, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'total_ht' => 'decimal:2',
|
||||
'total_tva' => 'decimal:2',
|
||||
'total_ttc' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function group()
|
||||
{
|
||||
return $this->belongsTo(ClientGroup::class, 'group_id');
|
||||
}
|
||||
|
||||
public function lines()
|
||||
{
|
||||
return $this->hasMany(InvoiceLine::class);
|
||||
}
|
||||
|
||||
public function sourceQuote()
|
||||
{
|
||||
return $this->belongsTo(Quote::class, 'source_quote_id');
|
||||
}
|
||||
|
||||
public function eInvoicingChannel()
|
||||
{
|
||||
return $this->belongsTo(EInvoicingChannel::class);
|
||||
}
|
||||
|
||||
public function history()
|
||||
{
|
||||
return $this->hasMany(DocumentStatusHistory::class, 'document_id')
|
||||
->where('document_type', 'invoice')
|
||||
->orderBy('changed_at', 'desc');
|
||||
}
|
||||
}
|
||||
48
thanasoft-back/app/Models/InvoiceLine.php
Normal file
48
thanasoft-back/app/Models/InvoiceLine.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InvoiceLine extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_id',
|
||||
'product_id',
|
||||
'packaging_id',
|
||||
'packages_qty',
|
||||
'units_qty',
|
||||
'description',
|
||||
'qty_base',
|
||||
'unit_price',
|
||||
'unit_price_per_package',
|
||||
'tva_rate_id',
|
||||
'discount_pct',
|
||||
'total_ht',
|
||||
];
|
||||
|
||||
public function invoice()
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function packaging()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Stock\ProductPackaging::class, 'packaging_id');
|
||||
}
|
||||
|
||||
public function tvaRate()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\TvaRate::class, 'tva_rate_id');
|
||||
}
|
||||
}
|
||||
@ -67,11 +67,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, \App\Repositories\ProductCategoryRepository::class);
|
||||
|
||||
$this->app->bind(\App\Repositories\InvoiceRepositoryInterface::class, \App\Repositories\InvoiceRepository::class);
|
||||
|
||||
$this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class);
|
||||
|
||||
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
|
||||
|
||||
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
|
||||
@ -23,8 +23,7 @@ class RepositoryServiceProvider extends ServiceProvider
|
||||
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
|
||||
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
|
||||
$this->app->bind(FileRepositoryInterface::class, FileRepository::class);
|
||||
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
|
||||
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
41
thanasoft-back/app/Repositories/InvoiceLineRepository.php
Normal file
41
thanasoft-back/app/Repositories/InvoiceLineRepository.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\InvoiceLine;
|
||||
|
||||
class InvoiceLineRepository implements InvoiceLineRepositoryInterface
|
||||
{
|
||||
public function create(array $data): InvoiceLine
|
||||
{
|
||||
return InvoiceLine::create($data);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): bool
|
||||
{
|
||||
$line = $this->find($id);
|
||||
if ($line) {
|
||||
return $line->update($data);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$line = $this->find($id);
|
||||
if ($line) {
|
||||
return $line->delete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function find(string $id): ?InvoiceLine
|
||||
{
|
||||
return InvoiceLine::find($id);
|
||||
}
|
||||
|
||||
public function getByInvoiceId(string $invoiceId)
|
||||
{
|
||||
return InvoiceLine::where('invoice_id', $invoiceId)->get();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\InvoiceLine;
|
||||
|
||||
interface InvoiceLineRepositoryInterface
|
||||
{
|
||||
public function create(array $data): InvoiceLine;
|
||||
public function update(string $id, array $data): bool;
|
||||
public function delete(string $id): bool;
|
||||
public function find(string $id): ?InvoiceLine;
|
||||
public function getByInvoiceId(string $invoiceId);
|
||||
}
|
||||
159
thanasoft-back/app/Repositories/InvoiceRepository.php
Normal file
159
thanasoft-back/app/Repositories/InvoiceRepository.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Quote;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
Invoice $model,
|
||||
protected InvoiceLineRepositoryInterface $invoiceLineRepository
|
||||
) {
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
public function createFromQuote(int|string $quoteId): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($quoteId) {
|
||||
// Resolve Quote directly to avoid circular dependency
|
||||
$quote = Quote::with(['client', 'lines'])->find($quoteId);
|
||||
|
||||
if (!$quote) {
|
||||
throw new \Exception("Quote not found");
|
||||
}
|
||||
|
||||
// Create Invoice
|
||||
$invoiceData = [
|
||||
'client_id' => $quote->client_id,
|
||||
'group_id' => $quote->group_id,
|
||||
'source_quote_id' => $quote->id,
|
||||
'status' => 'brouillon', // Start as draft
|
||||
'invoice_date' => now(),
|
||||
'currency' => $quote->currency,
|
||||
'total_ht' => $quote->total_ht,
|
||||
'total_tva' => $quote->total_tva,
|
||||
'total_ttc' => $quote->total_ttc,
|
||||
];
|
||||
|
||||
$invoice = parent::create($invoiceData);
|
||||
|
||||
// Copy Lines
|
||||
foreach ($quote->lines as $line) {
|
||||
$this->invoiceLineRepository->create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $line->product_id,
|
||||
'packaging_id' => $line->packaging_id,
|
||||
'packages_qty' => $line->packages_qty,
|
||||
'units_qty' => $line->units_qty,
|
||||
'description' => $line->description,
|
||||
'qty_base' => $line->qty_base,
|
||||
'unit_price' => $line->unit_price,
|
||||
'unit_price_per_package' => $line->unit_price_per_package,
|
||||
'tva_rate_id' => $line->tva_rate_id,
|
||||
'discount_pct' => $line->discount_pct,
|
||||
'total_ht' => $line->total_ht,
|
||||
]);
|
||||
}
|
||||
|
||||
// Record history
|
||||
$this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function all(array $columns = ['*']): \Illuminate\Support\Collection
|
||||
{
|
||||
return $this->model->with(['client', 'lines.product'])->get($columns);
|
||||
}
|
||||
|
||||
public function create(array $data): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
try {
|
||||
// Create the invoice
|
||||
$invoice = parent::create($data);
|
||||
|
||||
// Create the invoice lines
|
||||
if (isset($data['lines']) && is_array($data['lines'])) {
|
||||
foreach ($data['lines'] as $lineData) {
|
||||
$lineData['invoice_id'] = $invoice->id;
|
||||
$this->invoiceLineRepository->create($lineData);
|
||||
}
|
||||
}
|
||||
|
||||
// Record initial status history
|
||||
$this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created');
|
||||
|
||||
return $invoice;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating invoice with lines: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'data' => $data,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int|string $id, array $attributes): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id, $attributes) {
|
||||
try {
|
||||
$invoice = $this->find($id);
|
||||
if (!$invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldStatus = $invoice->status;
|
||||
|
||||
// Update the invoice
|
||||
$updated = parent::update($id, $attributes);
|
||||
|
||||
if ($updated) {
|
||||
$newStatus = $attributes['status'] ?? $oldStatus;
|
||||
|
||||
// If status changed, record history
|
||||
if ($oldStatus !== $newStatus) {
|
||||
$this->recordHistory((int) $id, $oldStatus, $newStatus, 'Invoice status updated');
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating invoice: ' . $e->getMessage(), [
|
||||
'id' => $id,
|
||||
'attributes' => $attributes,
|
||||
'exception' => $e,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function find(int|string $id, array $columns = ['*']): ?Invoice
|
||||
{
|
||||
return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns);
|
||||
}
|
||||
|
||||
private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void
|
||||
{
|
||||
\App\Models\DocumentStatusHistory::create([
|
||||
'document_type' => 'invoice',
|
||||
'document_id' => $invoiceId,
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $newStatus,
|
||||
'changed_by' => auth()->id(), // Assuming authenticated user
|
||||
'comment' => $comment,
|
||||
'changed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
interface InvoiceRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
public function createFromQuote(int|string $quoteId): \App\Models\Invoice;
|
||||
}
|
||||
@ -12,7 +12,8 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
Quote $model,
|
||||
protected QuoteLineRepositoryInterface $quoteLineRepository
|
||||
protected QuoteLineRepositoryInterface $quoteLineRepository,
|
||||
protected InvoiceRepositoryInterface $invoiceRepository
|
||||
) {
|
||||
parent::__construct($model);
|
||||
}
|
||||
@ -72,6 +73,20 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
||||
// If status changed, record history
|
||||
if ($oldStatus !== $newStatus) {
|
||||
$this->recordHistory((int) $id, $oldStatus, $newStatus, 'Quote status updated');
|
||||
|
||||
// Auto-create invoice when status changes to 'accepte'
|
||||
if ($newStatus === 'accepte' && $oldStatus !== 'accepte') {
|
||||
try {
|
||||
$this->invoiceRepository->createFromQuote($id);
|
||||
Log::info('Invoice auto-created from quote', ['quote_id' => $id]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to auto-create invoice from quote: ' . $e->getMessage(), [
|
||||
'quote_id' => $id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
// Don't throw - quote update should still succeed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('invoices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('client_id');
|
||||
$table->unsignedBigInteger('group_id')->nullable();
|
||||
$table->unsignedBigInteger('source_quote_id')->nullable();
|
||||
$table->string('invoice_number', 191);
|
||||
$table->enum('status', ['brouillon', 'emise', 'envoyee', 'partiellement_payee', 'payee', 'echue', 'annulee', 'avoir'])->default('brouillon');
|
||||
$table->date('invoice_date')->useCurrent();
|
||||
$table->date('due_date')->nullable();
|
||||
$table->char('currency', 3)->default('EUR');
|
||||
$table->decimal('total_ht', 14, 2)->default(0);
|
||||
$table->decimal('total_tva', 14, 2)->default(0);
|
||||
$table->decimal('total_ttc', 14, 2)->default(0);
|
||||
$table->enum('e_invoice_status', ['cree', 'transmis', 'accepte', 'refuse', 'en_litige', 'acquitte', 'archive'])->nullable()->default('cree');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'due_date'], 'idx_invoices_status_due');
|
||||
|
||||
$table->foreign('client_id', 'fk_invoices_client')->references('id')->on('clients');
|
||||
$table->foreign('group_id', 'fk_invoices_group')->references('id')->on('client_groups')->onDelete('set null');
|
||||
$table->foreign('source_quote_id', 'fk_invoices_quote')->references('id')->on('quotes')->onDelete('set null');
|
||||
|
||||
});
|
||||
|
||||
Schema::create('invoice_lines', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('invoice_id');
|
||||
$table->unsignedBigInteger('product_id')->nullable();
|
||||
$table->unsignedBigInteger('packaging_id')->nullable();
|
||||
$table->decimal('packages_qty', 14, 3)->nullable();
|
||||
$table->decimal('units_qty', 14, 3)->nullable();
|
||||
$table->text('description');
|
||||
$table->decimal('qty_base', 14, 3)->nullable();
|
||||
$table->decimal('unit_price', 12, 2);
|
||||
$table->decimal('unit_price_per_package', 12, 2)->nullable();
|
||||
$table->unsignedBigInteger('tva_rate_id')->nullable();
|
||||
$table->decimal('discount_pct', 5, 2)->default(0);
|
||||
$table->decimal('total_ht', 14, 2);
|
||||
|
||||
$table->foreign('invoice_id', 'fk_il_invoice')->references('id')->on('invoices')->onDelete('cascade');
|
||||
$table->foreign('product_id', 'fk_il_product')->references('id')->on('products')->onDelete('set null');
|
||||
// Note: packaging_id and tva_rate_id FK constraints removed - referenced tables don't exist
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('invoice_lines');
|
||||
Schema::dropIfExists('invoices');
|
||||
}
|
||||
};
|
||||
@ -72,6 +72,10 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
// Quote management
|
||||
Route::apiResource('quotes', QuoteController::class);
|
||||
|
||||
// Invoice management
|
||||
Route::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']);
|
||||
Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class);
|
||||
|
||||
// Fournisseur management
|
||||
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
|
||||
Route::apiResource('fournisseurs', FournisseurController::class);
|
||||
|
||||
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center py-5 text-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
<invoice-detail-template v-else-if="invoice">
|
||||
<template #header>
|
||||
<invoice-header
|
||||
:invoice-number="invoice.invoice_number"
|
||||
:date="invoice.invoice_date"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #lines>
|
||||
<invoice-lines-table :lines="invoice.lines" />
|
||||
</template>
|
||||
|
||||
<template #timeline>
|
||||
<div>
|
||||
<h6 class="mb-3 text-sm">Historique</h6>
|
||||
<div v-if="invoice.history && invoice.history.length > 0">
|
||||
<div v-for="(entry, index) in invoice.history" :key="index" class="mb-2">
|
||||
<span class="text-xs text-secondary">
|
||||
{{ formatDate(entry.changed_at) }}
|
||||
</span>
|
||||
<p class="text-xs mb-0">{{ entry.comment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-secondary">Aucun historique</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #billing>
|
||||
<div>
|
||||
<h6 class="mb-3 text-sm">Informations Client</h6>
|
||||
<p class="text-sm mb-1">
|
||||
<strong>{{ invoice.client ? invoice.client.name : 'Client inconnu' }}</strong>
|
||||
</p>
|
||||
<p class="text-xs text-secondary mb-1">
|
||||
{{ invoice.client ? invoice.client.email : '' }}
|
||||
</p>
|
||||
<p class="text-xs text-secondary mb-0">
|
||||
{{ invoice.client ? invoice.client.phone : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #summary>
|
||||
<invoice-summary
|
||||
:ht="invoice.total_ht"
|
||||
:tva="invoice.total_tva"
|
||||
:ttc="invoice.total_ttc"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="d-flex justify-content-end">
|
||||
<div class="dropdown d-inline-block me-2">
|
||||
<soft-button
|
||||
id="statusDropdown"
|
||||
color="secondary"
|
||||
variant="gradient"
|
||||
class="dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
>
|
||||
{{ getStatusLabel(invoice.status) }}
|
||||
</soft-button>
|
||||
<ul class="dropdown-menu" aria-labelledby="statusDropdown">
|
||||
<li v-for="status in availableStatuses" :key="status">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
:class="{ active: status === invoice.status }"
|
||||
href="javascript:;"
|
||||
@click="changeStatus(status)"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<soft-button color="info" variant="outline">
|
||||
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
||||
</soft-button>
|
||||
</div>
|
||||
</template>
|
||||
</invoice-detail-template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, defineProps } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useInvoiceStore } from "@/stores/invoiceStore";
|
||||
import { useNotificationStore } from "@/stores/notification";
|
||||
import InvoiceDetailTemplate from "@/components/templates/Invoice/InvoiceDetailTemplate.vue";
|
||||
import InvoiceHeader from "@/components/molecules/Invoice/InvoiceHeader.vue";
|
||||
import InvoiceLinesTable from "@/components/molecules/Invoice/InvoiceLinesTable.vue";
|
||||
import InvoiceSummary from "@/components/molecules/Invoice/InvoiceSummary.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
invoiceId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const invoiceStore = useInvoiceStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const invoice = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const fetchedInvoice = await invoiceStore.fetchInvoice(props.invoiceId);
|
||||
invoice.value = fetchedInvoice;
|
||||
} catch (e) {
|
||||
error.value = "Impossible de charger la facture.";
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleDateString("fr-FR");
|
||||
};
|
||||
|
||||
const availableStatuses = [
|
||||
"brouillon",
|
||||
"emise",
|
||||
"envoyee",
|
||||
"partiellement_payee",
|
||||
"payee",
|
||||
"echue",
|
||||
"annulee",
|
||||
"avoir",
|
||||
];
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
brouillon: "Brouillon",
|
||||
emise: "Émise",
|
||||
envoyee: "Envoyée",
|
||||
partiellement_payee: "Part. Payée",
|
||||
payee: "Payée",
|
||||
echue: "Échue",
|
||||
annulee: "Annulée",
|
||||
avoir: "Avoir",
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
/* eslint-disable require-atomic-updates */
|
||||
const changeStatus = async (newStatus) => {
|
||||
if (!invoice.value?.id) return;
|
||||
|
||||
const currentInvoiceId = invoice.value.id;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const updated = await invoiceStore.updateInvoice({
|
||||
id: currentInvoiceId,
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
if (invoice.value?.id === currentInvoiceId) {
|
||||
invoice.value = updated;
|
||||
|
||||
notificationStore.success(
|
||||
'Statut mis à jour',
|
||||
`La facture est maintenant "${getStatusLabel(newStatus)}"`,
|
||||
3000
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update status", e);
|
||||
notificationStore.error(
|
||||
'Erreur',
|
||||
'Impossible de mettre à jour le statut',
|
||||
3000
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<invoice-list-controls @create="openCreateModal" />
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<invoice-table
|
||||
:data="invoices"
|
||||
:loading="loading"
|
||||
@view="handleView"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRouter } from "vue-router";
|
||||
import InvoiceListControls from "@/components/molecules/Invoice/InvoiceListControls.vue";
|
||||
import InvoiceTable from "@/components/molecules/Tables/Ventes/InvoiceTable.vue";
|
||||
import { useInvoiceStore } from "@/stores/invoiceStore";
|
||||
|
||||
const router = useRouter();
|
||||
const invoiceStore = useInvoiceStore();
|
||||
const { invoices, loading } = storeToRefs(invoiceStore);
|
||||
|
||||
const openCreateModal = () => {
|
||||
router.push("/ventes/factures/new");
|
||||
};
|
||||
|
||||
const handleView = (id) => {
|
||||
router.push(`/ventes/factures/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette facture ?")) {
|
||||
await invoiceStore.deleteInvoice(id);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
invoiceStore.fetchInvoices();
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="card-header pb-0 p-3">
|
||||
<div class="row">
|
||||
<div class="col-6 d-flex align-items-center">
|
||||
<h6 class="mb-0">Facture {{ invoiceNumber }}</h6>
|
||||
</div>
|
||||
<div class="col-6 text-end">
|
||||
<span class="badge bg-gradient-info">{{ formattedDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
invoiceNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.date) return "-";
|
||||
return new Date(props.date).toLocaleDateString("fr-FR");
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||
Produit
|
||||
</th>
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">
|
||||
Description
|
||||
</th>
|
||||
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||
Quantité
|
||||
</th>
|
||||
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||
Prix Unit.
|
||||
</th>
|
||||
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||
Remise
|
||||
</th>
|
||||
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||
Total HT
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(line, index) in lines" :key="index">
|
||||
<td>
|
||||
<span class="text-xs font-weight-bold">{{ line.product_name || 'Produit' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-secondary">{{ line.description || '-' }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-xs font-weight-bold">{{ line.units_qty || line.qty_base || 1 }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-xs font-weight-bold">{{ formatCurrency(line.unit_price) }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-xs font-weight-bold">{{ line.discount_pct || 0 }}%</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-xs font-weight-bold">{{ formatCurrency(line.total_ht) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!lines || lines.length === 0">
|
||||
<td colspan="6" class="text-center text-secondary py-3">
|
||||
Aucune ligne
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
lines: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value || 0);
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="d-sm-flex justify-content-between">
|
||||
<div>
|
||||
<soft-button color="success" variant="gradient" @click="$emit('create')">
|
||||
Nouvelle Facture
|
||||
</soft-button>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="dropdown d-inline">
|
||||
<soft-button
|
||||
id="invoiceFilterDropdown"
|
||||
color="dark"
|
||||
variant="outline"
|
||||
class="dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Filtrer
|
||||
</soft-button>
|
||||
<ul
|
||||
class="dropdown-menu dropdown-menu-lg-start px-2 py-3"
|
||||
aria-labelledby="invoiceFilterDropdown"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', 'brouillon')"
|
||||
>
|
||||
Statut: Brouillon
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', 'emise')"
|
||||
>
|
||||
Statut: Émise
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', 'envoyee')"
|
||||
>
|
||||
Statut: Envoyée
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', 'payee')"
|
||||
>
|
||||
Statut: Payée
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', 'echue')"
|
||||
>
|
||||
Statut: Échue
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="horizontal dark my-2" />
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item border-radius-md text-danger"
|
||||
href="javascript:;"
|
||||
@click="$emit('filter', null)"
|
||||
>
|
||||
Retirer Filtres
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<soft-button
|
||||
class="btn-icon ms-2 export"
|
||||
color="dark"
|
||||
variant="outline"
|
||||
data-type="csv"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<span class="btn-inner--icon">
|
||||
<i class="ni ni-archive-2"></i>
|
||||
</span>
|
||||
<span class="btn-inner--text">Export CSV</span>
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits } from "vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
const emit = defineEmits(["create", "filter", "export"]);
|
||||
</script>
|
||||
@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<h6 class="mb-3 text-sm">Résumé</h6>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-xs">Total HT:</span>
|
||||
<span class="text-xs font-weight-bold">{{ formatCurrency(ht) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<span class="text-xs">TVA:</span>
|
||||
<span class="text-xs font-weight-bold">{{ formatCurrency(tva) }}</span>
|
||||
</div>
|
||||
<hr class="horizontal dark my-2" />
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-sm font-weight-bold">Total TTC:</span>
|
||||
<span class="text-sm font-weight-bold text-success">{{ formatCurrency(ttc) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
ht: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
tva: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
ttc: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value || 0);
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="card mt-4">
|
||||
<div class="table-responsive">
|
||||
<table id="invoice-list" class="table table-flush">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>Numéro</th>
|
||||
<th>Date</th>
|
||||
<th>Échéance</th>
|
||||
<th>Statut</th>
|
||||
<th>Client</th>
|
||||
<th>Total TTC</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invoice in data" :key="invoice.id">
|
||||
<!-- Invoice Number -->
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-checkbox class="me-2" />
|
||||
<p class="text-xs font-weight-bold ms-2 mb-0">
|
||||
{{ invoice.invoice_number }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="font-weight-bold">
|
||||
<span class="my-2 text-xs">{{
|
||||
formatDate(invoice.invoice_date)
|
||||
}}</span>
|
||||
</td>
|
||||
|
||||
<!-- Due Date -->
|
||||
<td class="font-weight-bold">
|
||||
<span class="my-2 text-xs" :class="getDueDateClass(invoice)">{{
|
||||
formatDate(invoice.due_date) || '-'
|
||||
}}</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="text-xs font-weight-bold">
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-button
|
||||
:color="getStatusColor(invoice.status)"
|
||||
variant="outline"
|
||||
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<i
|
||||
:class="getStatusIcon(invoice.status)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</soft-button>
|
||||
<span>{{ getStatusLabel(invoice.status) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Client -->
|
||||
<td class="text-xs font-weight-bold">
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
:img="getRandomAvatar()"
|
||||
class="me-2"
|
||||
size="xs"
|
||||
alt="user image"
|
||||
circular
|
||||
/>
|
||||
<span>{{
|
||||
invoice.client ? invoice.client.name : "Client Inconnu"
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Total TTC -->
|
||||
<td class="text-xs font-weight-bold">
|
||||
<span class="my-2 text-xs">{{
|
||||
formatCurrency(invoice.total_ttc)
|
||||
}}</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-xs font-weight-bold">
|
||||
<div class="d-flex align-items-center">
|
||||
<button
|
||||
class="btn btn-link text-secondary mb-0 px-2"
|
||||
:data-id="invoice.id"
|
||||
data-action="view"
|
||||
title="Voir la facture"
|
||||
>
|
||||
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link text-danger mb-0 px-2"
|
||||
:data-id="invoice.id"
|
||||
data-action="delete"
|
||||
title="Supprimer la facture"
|
||||
>
|
||||
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
watch,
|
||||
onUnmounted,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
} from "vue";
|
||||
import { DataTable } from "simple-datatables";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||
|
||||
// Sample avatar images
|
||||
import img1 from "@/assets/img/team-2.jpg";
|
||||
import img2 from "@/assets/img/team-1.jpg";
|
||||
import img3 from "@/assets/img/team-3.jpg";
|
||||
import img4 from "@/assets/img/team-4.jpg";
|
||||
import img5 from "@/assets/img/team-5.jpg";
|
||||
import img6 from "@/assets/img/ivana-squares.jpg";
|
||||
|
||||
const avatarImages = [img1, img2, img3, img4, img5, img6];
|
||||
|
||||
const emit = defineEmits(["view", "delete"]);
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const dataTableInstance = ref(null);
|
||||
|
||||
const getRandomAvatar = () => {
|
||||
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
||||
return avatarImages[randomIndex];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
return new Date(dateString).toLocaleDateString("fr-FR", options);
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getDueDateClass = (invoice) => {
|
||||
if (!invoice.due_date) return "";
|
||||
const dueDate = new Date(invoice.due_date);
|
||||
const today = new Date();
|
||||
if (dueDate < today && invoice.status !== "payee") {
|
||||
return "text-danger";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// Map status to colors and icons
|
||||
const getStatusColor = (status) => {
|
||||
const map = {
|
||||
brouillon: "secondary",
|
||||
emise: "info",
|
||||
envoyee: "primary",
|
||||
partiellement_payee: "warning",
|
||||
payee: "success",
|
||||
echue: "danger",
|
||||
annulee: "dark",
|
||||
avoir: "info",
|
||||
};
|
||||
return map[status] || "secondary";
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
const map = {
|
||||
brouillon: "fas fa-pen",
|
||||
emise: "fas fa-file-invoice",
|
||||
envoyee: "fas fa-paper-plane",
|
||||
partiellement_payee: "fas fa-hourglass-half",
|
||||
payee: "fas fa-check",
|
||||
echue: "fas fa-exclamation-circle",
|
||||
annulee: "fas fa-ban",
|
||||
avoir: "fas fa-undo",
|
||||
};
|
||||
return map[status] || "fas fa-info";
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
brouillon: "Brouillon",
|
||||
emise: "Émise",
|
||||
envoyee: "Envoyée",
|
||||
partiellement_payee: "Part. Payée",
|
||||
payee: "Payée",
|
||||
echue: "Échue",
|
||||
annulee: "Annulée",
|
||||
avoir: "Avoir",
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const initializeDataTable = () => {
|
||||
if (dataTableInstance.value) {
|
||||
dataTableInstance.value.destroy();
|
||||
dataTableInstance.value = null;
|
||||
}
|
||||
|
||||
const dataTableEl = document.getElementById("invoice-list");
|
||||
if (dataTableEl) {
|
||||
dataTableInstance.value = new DataTable(dataTableEl, {
|
||||
searchable: true,
|
||||
fixedHeight: false,
|
||||
perPageSelect: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.loading && props.data.length > 0) {
|
||||
initializeDataTable();
|
||||
}
|
||||
|
||||
// Event delegation
|
||||
const table = document.getElementById("invoice-list");
|
||||
if (table) {
|
||||
table.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("button");
|
||||
if (!btn) return;
|
||||
|
||||
const id = btn.getAttribute("data-id");
|
||||
const action = btn.getAttribute("data-action");
|
||||
|
||||
if (id && action) {
|
||||
if (action === "view") {
|
||||
emit("view", parseInt(id));
|
||||
} else if (action === "delete") {
|
||||
emit("delete", parseInt(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
if (!props.loading) {
|
||||
setTimeout(() => {
|
||||
initializeDataTable();
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dataTableInstance.value) {
|
||||
dataTableInstance.value.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card mb-4">
|
||||
<slot name="header"></slot>
|
||||
|
||||
<div class="card-body p-3 pt-0">
|
||||
<hr class="horizontal dark mt-0 mb-4" />
|
||||
|
||||
<!-- Product Lines Section -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<slot name="lines"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="horizontal dark mt-4 mb-4" />
|
||||
|
||||
<div class="row">
|
||||
<!-- Tracking/Timeline Section -->
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<slot name="timeline"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Billing Info Section -->
|
||||
<div class="col-lg-5 col-md-6 col-12">
|
||||
<slot name="billing"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<div class="col-lg-3 col-12 ms-auto">
|
||||
<slot name="summary"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer p-3">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-sm-flex justify-content-between">
|
||||
<div>
|
||||
<slot name="invoice-new-action"></slot>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="dropdown d-inline">
|
||||
<slot name="select-filter"></slot>
|
||||
</div>
|
||||
<slot name="invoice-other-action"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mt-4">
|
||||
<slot name="invoice-table"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script></script>
|
||||
@ -274,7 +274,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: "factures-ventes",
|
||||
route: { name: "Factures ventes" },
|
||||
route: { name: "Liste Factures" },
|
||||
miniIcon: "F",
|
||||
text: "Factures",
|
||||
},
|
||||
|
||||
@ -487,16 +487,27 @@ const routes = [
|
||||
name: "Quote Details",
|
||||
component: () => import("@/views/pages/Ventes/QuoteDetail.vue"),
|
||||
},
|
||||
{
|
||||
path: "/ventes/factures",
|
||||
name: "Factures ventes",
|
||||
component: () => import("@/views/pages/Ventes/Factures.vue"),
|
||||
},
|
||||
{
|
||||
path: "/ventes/devis/new",
|
||||
name: "Nouveau Devis",
|
||||
component: () => import("@/views/pages/Ventes/NewQuote.vue"),
|
||||
},
|
||||
// Invoices
|
||||
{
|
||||
path: "/ventes/factures",
|
||||
name: "Liste Factures",
|
||||
component: () => import("@/views/pages/Ventes/InvoiceList.vue"),
|
||||
},
|
||||
{
|
||||
path: "/ventes/factures/new",
|
||||
name: "Nouvelle Facture",
|
||||
component: () => import("@/views/pages/Ventes/NewInvoice.vue"),
|
||||
},
|
||||
{
|
||||
path: "/ventes/factures/:id",
|
||||
name: "Invoice Details",
|
||||
component: () => import("@/views/pages/Ventes/InvoiceDetail.vue"),
|
||||
},
|
||||
// Client Groups
|
||||
{
|
||||
path: "/clients/groups",
|
||||
|
||||
176
thanasoft-front/src/services/invoice.ts
Normal file
176
thanasoft-front/src/services/invoice.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { request } from "./http";
|
||||
import { Client } from "./client";
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
client_id: number;
|
||||
group_id: number | null;
|
||||
source_quote_id: number | null;
|
||||
invoice_number: string;
|
||||
status:
|
||||
| "brouillon"
|
||||
| "emise"
|
||||
| "envoyee"
|
||||
| "partiellement_payee"
|
||||
| "payee"
|
||||
| "echue"
|
||||
| "annulee"
|
||||
| "avoir";
|
||||
invoice_date: string;
|
||||
due_date: string | null;
|
||||
currency: string;
|
||||
total_ht: number;
|
||||
total_tva: number;
|
||||
total_ttc: number;
|
||||
e_invoicing_channel_id: number | null;
|
||||
e_invoice_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client?: Client;
|
||||
}
|
||||
|
||||
export interface InvoiceListResponse {
|
||||
data: Invoice[];
|
||||
meta?: {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvoiceResponse {
|
||||
data: Invoice;
|
||||
}
|
||||
|
||||
export interface InvoiceLine {
|
||||
product_id: number | null;
|
||||
product_name: string;
|
||||
packaging_id?: number | null;
|
||||
packages_qty?: number | null;
|
||||
units_qty?: number | null;
|
||||
description?: string;
|
||||
qty_base?: number | null;
|
||||
unit_price: number;
|
||||
unit_price_per_package?: number | null;
|
||||
tva_rate_id?: number | null;
|
||||
discount_pct?: number;
|
||||
quantity: number;
|
||||
tva?: number;
|
||||
total_ht?: number;
|
||||
}
|
||||
|
||||
export interface CreateInvoicePayload {
|
||||
client_id: number;
|
||||
group_id?: number | null;
|
||||
source_quote_id?: number | null;
|
||||
status:
|
||||
| "brouillon"
|
||||
| "emise"
|
||||
| "envoyee"
|
||||
| "partiellement_payee"
|
||||
| "payee"
|
||||
| "echue"
|
||||
| "annulee"
|
||||
| "avoir";
|
||||
invoice_date: string;
|
||||
due_date?: string | null;
|
||||
currency: string;
|
||||
total_ht: number;
|
||||
total_tva: number;
|
||||
total_ttc: number;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
export interface UpdateInvoicePayload extends Partial<CreateInvoicePayload> {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const InvoiceService = {
|
||||
/**
|
||||
* Get all invoices with pagination
|
||||
*/
|
||||
async getAllInvoices(params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
client_id?: number;
|
||||
}): Promise<InvoiceListResponse> {
|
||||
const response = await request<InvoiceListResponse>({
|
||||
url: "/api/invoices",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific invoice by ID
|
||||
*/
|
||||
async getInvoice(id: number): Promise<InvoiceResponse> {
|
||||
const response = await request<InvoiceResponse>({
|
||||
url: `/api/invoices/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new invoice
|
||||
*/
|
||||
async createInvoice(payload: CreateInvoicePayload): Promise<InvoiceResponse> {
|
||||
const response = await request<InvoiceResponse>({
|
||||
url: "/api/invoices",
|
||||
method: "post",
|
||||
data: payload,
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an invoice from a quote
|
||||
*/
|
||||
async createFromQuote(quoteId: number): Promise<InvoiceResponse> {
|
||||
const response = await request<InvoiceResponse>({
|
||||
url: `/api/invoices/from-quote/${quoteId}`,
|
||||
method: "post",
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing invoice
|
||||
*/
|
||||
async updateInvoice(payload: UpdateInvoicePayload): Promise<InvoiceResponse> {
|
||||
const { id, ...updateData } = payload;
|
||||
|
||||
const response = await request<InvoiceResponse>({
|
||||
url: `/api/invoices/${id}`,
|
||||
method: "put",
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an invoice
|
||||
*/
|
||||
async deleteInvoice(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await request<{ success: boolean; message: string }>({
|
||||
url: `/api/invoices/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
export default InvoiceService;
|
||||
245
thanasoft-front/src/stores/invoiceStore.ts
Normal file
245
thanasoft-front/src/stores/invoiceStore.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import InvoiceService, {
|
||||
Invoice,
|
||||
CreateInvoicePayload,
|
||||
UpdateInvoicePayload,
|
||||
} from "@/services/invoice";
|
||||
|
||||
export const useInvoiceStore = defineStore("invoice", () => {
|
||||
// State
|
||||
const invoices = ref<Invoice[]>([]);
|
||||
const currentInvoice = ref<Invoice | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// Getters
|
||||
const allInvoices = computed(() => invoices.value);
|
||||
const isLoading = computed(() => loading.value);
|
||||
const hasError = computed(() => error.value !== null);
|
||||
const getError = computed(() => error.value);
|
||||
const getInvoiceById = computed(() => (id: number) =>
|
||||
invoices.value.find((invoice) => invoice.id === id)
|
||||
);
|
||||
const getPagination = computed(() => pagination.value);
|
||||
|
||||
// Actions
|
||||
const setLoading = (isLoading: boolean) => {
|
||||
loading.value = isLoading;
|
||||
};
|
||||
|
||||
const setError = (err: string | null) => {
|
||||
error.value = err;
|
||||
};
|
||||
|
||||
const setInvoices = (newInvoices: Invoice[]) => {
|
||||
invoices.value = newInvoices;
|
||||
};
|
||||
|
||||
const setCurrentInvoice = (invoice: Invoice | null) => {
|
||||
currentInvoice.value = invoice;
|
||||
};
|
||||
|
||||
const setPagination = (meta: any) => {
|
||||
if (meta) {
|
||||
pagination.value = {
|
||||
current_page: meta.current_page || 1,
|
||||
last_page: meta.last_page || 1,
|
||||
per_page: meta.per_page || 10,
|
||||
total: meta.total || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all invoices with optional pagination and filters
|
||||
*/
|
||||
const fetchInvoices = async (params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
client_id?: number;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.getAllInvoices(params);
|
||||
setInvoices(response.data);
|
||||
if (response.meta) {
|
||||
setPagination(response.meta);
|
||||
}
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to fetch invoices";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single invoice by ID
|
||||
*/
|
||||
const fetchInvoice = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.getInvoice(id);
|
||||
setCurrentInvoice(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to fetch invoice";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new invoice
|
||||
*/
|
||||
const createInvoice = async (payload: CreateInvoicePayload) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.createInvoice(payload);
|
||||
invoices.value.push(response.data);
|
||||
setCurrentInvoice(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to create invoice";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an invoice from a quote
|
||||
*/
|
||||
const createFromQuote = async (quoteId: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.createFromQuote(quoteId);
|
||||
invoices.value.push(response.data);
|
||||
setCurrentInvoice(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to create invoice from quote";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing invoice
|
||||
*/
|
||||
const updateInvoice = async (payload: UpdateInvoicePayload) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.updateInvoice(payload);
|
||||
const updatedInvoice = response.data;
|
||||
|
||||
// Update in the invoices list
|
||||
const index = invoices.value.findIndex(
|
||||
(invoice) => invoice.id === updatedInvoice.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
invoices.value[index] = updatedInvoice;
|
||||
}
|
||||
|
||||
// Update current invoice if it's the one being edited
|
||||
if (currentInvoice.value && currentInvoice.value.id === updatedInvoice.id) {
|
||||
setCurrentInvoice(updatedInvoice);
|
||||
}
|
||||
|
||||
return updatedInvoice;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to update invoice";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an invoice
|
||||
*/
|
||||
const deleteInvoice = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await InvoiceService.deleteInvoice(id);
|
||||
|
||||
// Remove from the invoices list
|
||||
invoices.value = invoices.value.filter((invoice) => invoice.id !== id);
|
||||
|
||||
// Clear current invoice if it's the one being deleted
|
||||
if (currentInvoice.value && currentInvoice.value.id === id) {
|
||||
setCurrentInvoice(null);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to delete invoice";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
invoices,
|
||||
currentInvoice,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
|
||||
// Getters
|
||||
allInvoices,
|
||||
isLoading,
|
||||
hasError,
|
||||
getError,
|
||||
getInvoiceById,
|
||||
getPagination,
|
||||
|
||||
// Actions
|
||||
fetchInvoices,
|
||||
fetchInvoice,
|
||||
createInvoice,
|
||||
createFromQuote,
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
};
|
||||
});
|
||||
12
thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue
Normal file
12
thanasoft-front/src/views/pages/Ventes/InvoiceDetail.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<invoice-detail-presentation :invoice-id="invoiceId" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import InvoiceDetailPresentation from "@/components/Organism/Invoice/InvoiceDetailPresentation.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const invoiceId = computed(() => route.params.id);
|
||||
</script>
|
||||
7
thanasoft-front/src/views/pages/Ventes/InvoiceList.vue
Normal file
7
thanasoft-front/src/views/pages/Ventes/InvoiceList.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<invoice-list-presentation />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import InvoiceListPresentation from "@/components/Organism/Invoice/InvoiceListPresentation.vue";
|
||||
</script>
|
||||
46
thanasoft-front/src/views/pages/Ventes/NewInvoice.vue
Normal file
46
thanasoft-front/src/views/pages/Ventes/NewInvoice.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header pb-0 p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Nouvelle Facture</h6>
|
||||
<soft-button color="secondary" variant="outline" size="sm" @click="goBack">
|
||||
<i class="fas fa-arrow-left me-2"></i>Retour
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
La création de facture directe sera disponible prochainement.
|
||||
Pour l'instant, vous pouvez créer une facture à partir d'un devis accepté.
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<soft-button color="success" variant="gradient" @click="goToQuotes">
|
||||
<i class="fas fa-file-invoice me-2"></i>
|
||||
Voir les Devis
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const goBack = () => {
|
||||
router.push("/ventes/factures");
|
||||
};
|
||||
|
||||
const goToQuotes = () => {
|
||||
router.push("/ventes/devis");
|
||||
};
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user