quotes: Generer des factures en acceptant un devis

This commit is contained in:
kevin 2026-01-09 18:02:15 +03:00
parent e0ccd5f627
commit 503fb0d008
32 changed files with 2207 additions and 9 deletions

View 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);
}
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View 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'),
];
}
}

View 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')),
];
}
}

View 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');
}
}

View 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');
}
}

View File

@ -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.
*/

View File

@ -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);
}
/**

View 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();
}
}

View File

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

View 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(),
]);
}
}

View File

@ -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;
}

View File

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

View File

@ -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');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 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>

View File

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

View File

@ -274,7 +274,7 @@ export default {
},
{
id: "factures-ventes",
route: { name: "Factures ventes" },
route: { name: "Liste Factures" },
miniIcon: "F",
text: "Factures",
},

View File

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

View 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;

View 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,
};
});

View 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>

View File

@ -0,0 +1,7 @@
<template>
<invoice-list-presentation />
</template>
<script setup>
import InvoiceListPresentation from "@/components/Organism/Invoice/InvoiceListPresentation.vue";
</script>

View 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>