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\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\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap any application services.
|
* Bootstrap any application services.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -23,8 +23,7 @@ class RepositoryServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
|
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
|
||||||
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
|
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
|
||||||
$this->app->bind(FileRepositoryInterface::class, FileRepository::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(
|
public function __construct(
|
||||||
Quote $model,
|
Quote $model,
|
||||||
protected QuoteLineRepositoryInterface $quoteLineRepository
|
protected QuoteLineRepositoryInterface $quoteLineRepository,
|
||||||
|
protected InvoiceRepositoryInterface $invoiceRepository
|
||||||
) {
|
) {
|
||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
@ -72,6 +73,20 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
// If status changed, record history
|
// If status changed, record history
|
||||||
if ($oldStatus !== $newStatus) {
|
if ($oldStatus !== $newStatus) {
|
||||||
$this->recordHistory((int) $id, $oldStatus, $newStatus, 'Quote status updated');
|
$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
|
// Quote management
|
||||||
Route::apiResource('quotes', QuoteController::class);
|
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
|
// Fournisseur management
|
||||||
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
|
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
|
||||||
Route::apiResource('fournisseurs', FournisseurController::class);
|
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",
|
id: "factures-ventes",
|
||||||
route: { name: "Factures ventes" },
|
route: { name: "Liste Factures" },
|
||||||
miniIcon: "F",
|
miniIcon: "F",
|
||||||
text: "Factures",
|
text: "Factures",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -487,16 +487,27 @@ const routes = [
|
|||||||
name: "Quote Details",
|
name: "Quote Details",
|
||||||
component: () => import("@/views/pages/Ventes/QuoteDetail.vue"),
|
component: () => import("@/views/pages/Ventes/QuoteDetail.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/ventes/factures",
|
|
||||||
name: "Factures ventes",
|
|
||||||
component: () => import("@/views/pages/Ventes/Factures.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/ventes/devis/new",
|
path: "/ventes/devis/new",
|
||||||
name: "Nouveau Devis",
|
name: "Nouveau Devis",
|
||||||
component: () => import("@/views/pages/Ventes/NewQuote.vue"),
|
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
|
// Client Groups
|
||||||
{
|
{
|
||||||
path: "/clients/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