migration Commandes fournisseurs, les Lignes de commandes de marchandises, Lignes de réception, Factures fournisseurs

This commit is contained in:
kevin 2026-01-26 12:02:28 +03:00
parent f62a2db36e
commit 3bc4178a12
35 changed files with 2038 additions and 7 deletions

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreAvoirRequest;
use App\Http\Requests\UpdateAvoirRequest;
use App\Http\Resources\AvoirResource;
use App\Repositories\AvoirRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
class AvoirController extends Controller
{
public function __construct(
protected AvoirRepositoryInterface $avoirRepository
) {
}
/**
* Display a listing of credits.
*/
public function index(): AnonymousResourceCollection|JsonResponse
{
try {
$avoirs = $this->avoirRepository->all();
return AvoirResource::collection($avoirs);
} catch (\Exception $e) {
Log::error('Error fetching avoirs: ' . $e->getMessage(), [
'exception' => $e,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des avoirs.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created credit.
*/
public function store(StoreAvoirRequest $request): AvoirResource|JsonResponse
{
try {
$avoir = $this->avoirRepository->create($request->validated());
return new AvoirResource($avoir);
} catch (\Exception $e) {
Log::error('Error creating avoir: ' . $e->getMessage(), [
'exception' => $e,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création de l\'avoir.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified credit.
*/
public function show(string $id): AvoirResource|JsonResponse
{
try {
$avoir = $this->avoirRepository->find($id);
if (!$avoir) {
return response()->json([
'message' => 'Avoir non trouvé.',
], 404);
}
return new AvoirResource($avoir);
} catch (\Exception $e) {
Log::error('Error fetching avoir: ' . $e->getMessage(), [
'exception' => $e,
'avoir_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de l\'avoir.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified credit.
*/
public function update(UpdateAvoirRequest $request, string $id): AvoirResource|JsonResponse
{
try {
$updated = $this->avoirRepository->update($id, $request->validated());
if (!$updated) {
return response()->json([
'message' => 'Avoir non trouvé ou échec de la mise à jour.',
], 404);
}
$avoir = $this->avoirRepository->find($id);
return new AvoirResource($avoir);
} catch (\Exception $e) {
Log::error('Error updating avoir: ' . $e->getMessage(), [
'exception' => $e,
'avoir_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour de l\'avoir.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified credit.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->avoirRepository->delete($id);
if (!$deleted) {
return response()->json([
'message' => 'Avoir non trouvé ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Avoir supprimé avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error deleting avoir: ' . $e->getMessage(), [
'exception' => $e,
'avoir_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de l\'avoir.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreAvoirRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'client_id' => 'required|exists:clients,id',
'invoice_id' => 'nullable|exists:invoices,id',
'group_id' => 'nullable|exists:client_groups,id',
'status' => 'required|in:brouillon,emis,applique,annule',
'avoir_date' => 'required|date',
'due_date' => 'nullable|date|after_or_equal:avoir_date',
'currency' => 'required|string|size:3',
'total_ht' => 'required|numeric',
'total_tva' => 'required|numeric',
'total_ttc' => 'required|numeric',
'reason_type' => 'required|in:remboursement_total,remboursement_partiel,reduction,erreur_facturation,retour_marchandise,accord_commercial,autre',
'reason_description' => 'nullable|string',
'e_invoice_status' => 'nullable|in:cree,transmis,accepte,refuse,en_litige,acquitte,archive',
'refund_status' => 'nullable|in:non_rembourse,en_cours,partiellement_rembourse,rembourse,compense',
'refund_date' => 'nullable|date',
'refund_method' => 'nullable|in:virement,cheque,carte_credit,compensation_future,autre',
'compensation_invoice_id' => 'nullable|exists:invoices,id',
'compensation_amount' => 'nullable|numeric|min:0',
'lines' => 'required|array|min:1',
'lines.*.product_id' => 'nullable|exists:products,id',
'lines.*.invoice_line_id' => 'nullable|exists:invoice_lines,id',
'lines.*.description' => 'required|string',
'lines.*.quantity' => 'required|numeric',
'lines.*.unit_price' => 'required|numeric',
'lines.*.tva_rate' => 'required|numeric',
'lines.*.total_ht' => 'required|numeric',
'lines.*.total_tva' => 'required|numeric',
'lines.*.total_ttc' => 'required|numeric',
'lines.*.notes' => 'nullable|string',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAvoirRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => 'sometimes|required|in:brouillon,emis,applique,annule',
'avoir_date' => 'sometimes|required|date',
'due_date' => 'nullable|date|after_or_equal:avoir_date',
'refund_status' => 'nullable|in:non_rembourse,en_cours,partiellement_rembourse,rembourse,compense',
'refund_date' => 'nullable|date',
'refund_method' => 'nullable|in:virement,cheque,carte_credit,compensation_future,autre',
'reason_description' => 'nullable|string',
'notes' => 'nullable|string',
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AvoirLineResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'avoir_id' => $this->avoir_id,
'product_id' => $this->product_id,
'invoice_line_id' => $this->invoice_line_id,
'description' => $this->description,
'quantity' => $this->quantity,
'unit_price' => $this->unit_price,
'tva_rate' => $this->tva_rate,
'total_ht' => $this->total_ht,
'total_tva' => $this->total_tva,
'total_ttc' => $this->total_ttc,
'notes' => $this->notes,
'product' => $this->whenLoaded('product'),
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AvoirResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->client_id,
'invoice_id' => $this->invoice_id,
'group_id' => $this->group_id,
'avoir_number' => $this->avoir_number,
'status' => $this->status,
'avoir_date' => $this->avoir_date,
'due_date' => $this->due_date,
'currency' => $this->currency,
'total_ht' => $this->total_ht,
'total_tva' => $this->total_tva,
'total_ttc' => $this->total_ttc,
'reason_type' => $this->reason_type,
'reason_description' => $this->reason_description,
'e_invoice_status' => $this->e_invoice_status,
'refund_status' => $this->refund_status,
'refund_date' => $this->refund_date,
'refund_method' => $this->refund_method,
'compensation_invoice_id' => $this->compensation_invoice_id,
'compensation_amount' => $this->compensation_amount,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'client' => $this->whenLoaded('client'),
'invoice' => $this->whenLoaded('invoice'),
'group' => $this->whenLoaded('group'),
'lines' => AvoirLineResource::collection($this->whenLoaded('lines')),
'history' => DocumentStatusHistoryResource::collection($this->whenLoaded('history')),
];
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Avoir extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'invoice_id',
'group_id',
'avoir_number',
'status',
'avoir_date',
'due_date',
'currency',
'total_ht',
'total_tva',
'total_ttc',
'reason_type',
'reason_description',
'e_invoice_status',
'refund_status',
'refund_date',
'refund_method',
'compensation_invoice_id',
'compensation_amount',
];
protected $casts = [
'avoir_date' => 'date',
'due_date' => 'date',
'refund_date' => 'date',
'total_ht' => 'decimal:2',
'total_tva' => 'decimal:2',
'total_ttc' => 'decimal:2',
'compensation_amount' => 'decimal:2',
];
protected static function booted()
{
static::creating(function ($avoir) {
// Auto-generate avoir number if not provided
if (empty($avoir->avoir_number)) {
$prefix = 'AV-' . now()->format('Ym') . '-';
$lastAvoir = self::where('avoir_number', 'like', $prefix . '%')
->orderBy('avoir_number', 'desc')
->first();
if ($lastAvoir) {
// Extract numeric part
preg_match('/(\d+)$/', $lastAvoir->avoir_number, $matches);
$newNumber = $matches ? intval($matches[1]) + 1 : 1;
} else {
$newNumber = 1;
}
$avoir->avoir_number = $prefix . str_pad((string)$newNumber, 4, '0', STR_PAD_LEFT);
}
});
}
public function client()
{
return $this->belongsTo(Client::class);
}
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function group()
{
return $this->belongsTo(ClientGroup::class, 'group_id');
}
public function lines()
{
return $this->hasMany(AvoirLine::class);
}
public function compensationInvoice()
{
return $this->belongsTo(Invoice::class, 'compensation_invoice_id');
}
public function history()
{
return $this->hasMany(DocumentStatusHistory::class, 'document_id')
->where('document_type', 'avoir')
->orderBy('changed_at', 'desc');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AvoirLine extends Model
{
use HasFactory;
protected $fillable = [
'avoir_id',
'product_id',
'invoice_line_id',
'description',
'quantity',
'unit_price',
'tva_rate',
'total_ht',
'total_tva',
'total_ttc',
'notes',
];
protected $casts = [
'quantity' => 'decimal:3',
'unit_price' => 'decimal:4',
'tva_rate' => 'decimal:2',
'total_ht' => 'decimal:2',
'total_tva' => 'decimal:2',
'total_ttc' => 'decimal:2',
];
public function avoir()
{
return $this->belongsTo(Avoir::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
public function invoiceLine()
{
return $this->belongsTo(InvoiceLine::class);
}
}

View File

@ -81,6 +81,16 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class); $this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class);
$this->app->bind(\App\Repositories\AvoirRepositoryInterface::class, function ($app) {
return new \App\Repositories\AvoirRepository(
$app->make(\App\Models\Avoir::class),
$app->make(\App\Repositories\AvoirLineRepositoryInterface::class),
$app->make(\App\Repositories\ClientActivityTimelineRepositoryInterface::class)
);
});
$this->app->bind(\App\Repositories\AvoirLineRepositoryInterface::class, \App\Repositories\AvoirLineRepository::class);
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, function ($app) { $this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, function ($app) {
return new \App\Repositories\QuoteRepository( return new \App\Repositories\QuoteRepository(
$app->make(\App\Models\Quote::class), $app->make(\App\Models\Quote::class),

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\AvoirLine;
class AvoirLineRepository extends BaseRepository implements AvoirLineRepositoryInterface
{
public function __construct(AvoirLine $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface AvoirLineRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Avoir;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AvoirRepository extends BaseRepository implements AvoirRepositoryInterface
{
public function __construct(
Avoir $model,
protected AvoirLineRepositoryInterface $avoirLineRepository,
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
) {
parent::__construct($model);
}
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
return $this->model->with(['client', 'lines.product'])->get($columns);
}
public function create(array $data): Avoir
{
return DB::transaction(function () use ($data) {
try {
// Create the avoir
$avoir = parent::create($data);
// Create the avoir lines
if (isset($data['lines']) && is_array($data['lines'])) {
foreach ($data['lines'] as $lineData) {
$lineData['avoir_id'] = $avoir->id;
$this->avoirLineRepository->create($lineData);
}
}
// Record initial status history
$this->recordHistory((int)$avoir->id, null, $avoir->status, 'Avoir created');
try {
$this->timelineRepository->logActivity([
'client_id' => $avoir->client_id,
'actor_type' => 'user',
'actor_user_id' => auth()->id(),
'event_type' => 'avoir_created',
'entity_type' => 'avoir',
'entity_id' => $avoir->id,
'title' => 'Nouvel avoir créé',
'description' => "L'avoir #{$avoir->avoir_number} a été créé.",
'created_at' => now(),
]);
} catch (\Exception $e) {
Log::error("Failed to log avoir creation activity: " . $e->getMessage());
}
return $avoir;
} catch (\Exception $e) {
Log::error('Error creating avoir 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 {
$avoir = $this->find($id);
if (!$avoir) {
return false;
}
$oldStatus = $avoir->status;
// Update the avoir
$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, 'Avoir status updated');
}
}
return $updated;
} catch (\Exception $e) {
Log::error('Error updating avoir: ' . $e->getMessage(), [
'id' => $id,
'attributes' => $attributes,
'exception' => $e,
]);
throw $e;
}
});
}
public function find(int|string $id, array $columns = ['*']): ?Avoir
{
return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $avoirId, ?string $oldStatus, string $newStatus, ?string $comment = null): void
{
\App\Models\DocumentStatusHistory::create([
'document_type' => 'avoir',
'document_id' => $avoirId,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => auth()->id(),
'comment' => $comment,
'changed_at' => now(),
]);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface AvoirRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -0,0 +1,112 @@
<?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('avoirs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('client_id');
$table->unsignedBigInteger('invoice_id')->nullable(); // The original invoice being credited
$table->unsignedBigInteger('group_id')->nullable();
$table->string('avoir_number', 191);
$table->enum('status', ['brouillon', 'emis', 'applique', 'annule'])->default('brouillon');
// Dates
$table->date('avoir_date')->useCurrent();
$table->date('due_date')->nullable();
// Currency and amounts (typically negative or representing credit)
$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);
// Reason for credit note
$table->enum('reason_type', [
'remboursement_total',
'remboursement_partiel',
'reduction',
'erreur_facturation',
'retour_marchandise',
'accord_commercial',
'autre'
])->default('autre');
$table->text('reason_description')->nullable();
// E-invoicing status (if applicable)
$table->enum('e_invoice_status', [
'cree', 'transmis', 'accepte', 'refuse', 'en_litige', 'acquitte', 'archive'
])->nullable()->default('cree');
// Refund information (for accounting reconciliation)
$table->enum('refund_status', [
'non_rembourse', 'en_cours', 'partiellement_rembourse', 'rembourse', 'compense'
])->default('non_rembourse');
$table->date('refund_date')->nullable();
$table->enum('refund_method', [
'virement', 'cheque', 'carte_credit', 'compensation_future', 'autre'
])->nullable();
// Compensation (if credit is applied to future invoices)
$table->unsignedBigInteger('compensation_invoice_id')->nullable();
$table->decimal('compensation_amount', 14, 2)->default(0);
$table->timestamps();
// Indexes for performance
$table->index(['status', 'due_date'], 'idx_avoirs_status_due');
$table->index('invoice_id', 'idx_avoirs_invoice');
$table->index('avoir_number', 'idx_avoirs_number');
$table->index('refund_status', 'idx_avoirs_refund_status');
$table->index('avoir_date', 'idx_avoirs_date');
// Foreign key constraints
$table->foreign('client_id', 'fk_avoirs_client')->references('id')->on('clients')->onDelete('cascade');
$table->foreign('invoice_id', 'fk_avoirs_invoice')->references('id')->on('invoices')->onDelete('restrict');
$table->foreign('group_id', 'fk_avoirs_group')->references('id')->on('client_groups')->onDelete('set null');
$table->foreign('compensation_invoice_id', 'fk_avoirs_compensation')->references('id')->on('invoices')->onDelete('set null');
});
Schema::create('avoir_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('avoir_id');
$table->unsignedBigInteger('product_id')->nullable();
$table->unsignedBigInteger('invoice_line_id')->nullable(); // Reference to the original invoice line if applicable
$table->string('description', 500);
$table->decimal('quantity', 10, 3)->default(1);
$table->decimal('unit_price', 14, 4);
$table->decimal('tva_rate', 5, 2)->default(0);
$table->decimal('total_ht', 14, 2);
$table->decimal('total_tva', 14, 2)->default(0);
$table->decimal('total_ttc', 14, 2)->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->index('avoir_id', 'idx_avoir_lines_avoir');
$table->foreign('avoir_id', 'fk_avoir_lines_avoir')->references('id')->on('avoirs')->onDelete('cascade');
$table->foreign('product_id', 'fk_avoir_lines_product')->references('id')->on('products')->onDelete('set null');
$table->foreign('invoice_line_id', 'fk_avoir_lines_invoice_line')->references('id')->on('invoice_lines')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('avoir_lines');
Schema::dropIfExists('avoirs');
}
};

View File

@ -0,0 +1,152 @@
<?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
{
// Purchase Orders (Commandes Fournisseurs)
Schema::create('purchase_orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('fournisseur_id'); // Use existing fournisseurs table
$table->string('po_number', 191);
$table->enum('status', ['brouillon', 'confirmee', 'livree', 'facturee', 'annulee'])->default('brouillon');
$table->date('order_date')->useCurrent();
$table->date('expected_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->text('notes')->nullable();
$table->string('delivery_address')->nullable();
$table->timestamps();
$table->unique('po_number', 'uq_po_number');
$table->index('status', 'idx_po_status');
$table->index('order_date', 'idx_po_order_date');
$table->foreign('fournisseur_id', 'fk_po_fournisseur')->references('id')->on('fournisseurs')->onDelete('cascade');
});
// Purchase Order Lines
Schema::create('purchase_order_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('purchase_order_id');
$table->unsignedBigInteger('product_id')->nullable();
$table->text('description');
$table->decimal('quantity', 14, 3)->default(1);
$table->decimal('unit_price', 12, 2);
$table->decimal('tva_rate', 5, 2)->default(0); // Use rate directly, no FK to tva_rates
$table->decimal('discount_pct', 5, 2)->default(0);
$table->decimal('total_ht', 14, 2);
$table->timestamps();
$table->foreign('purchase_order_id', 'fk_pol_po')->references('id')->on('purchase_orders')->onDelete('cascade');
$table->foreign('product_id', 'fk_pol_product')->references('id')->on('products')->onDelete('set null');
});
// Goods Receipts (Réceptions de marchandises)
Schema::create('goods_receipts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('purchase_order_id');
$table->string('receipt_number', 191);
$table->date('receipt_date')->useCurrent();
$table->enum('status', ['brouillon', 'valide', 'annule'])->default('brouillon');
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['purchase_order_id', 'receipt_number'], 'uq_gr_po_number');
$table->foreign('purchase_order_id', 'fk_gr_po')->references('id')->on('purchase_orders')->onDelete('cascade');
});
// Goods Receipt Lines
Schema::create('goods_receipt_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('goods_receipt_id');
$table->unsignedBigInteger('product_id');
$table->unsignedBigInteger('purchase_order_line_id')->nullable(); // Link to original order line
$table->decimal('quantity_received', 14, 3);
$table->decimal('unit_price', 12, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->foreign('goods_receipt_id', 'fk_grl_gr')->references('id')->on('goods_receipts')->onDelete('cascade');
$table->foreign('product_id', 'fk_grl_product')->references('id')->on('products');
$table->foreign('purchase_order_line_id', 'fk_grl_pol')->references('id')->on('purchase_order_lines')->onDelete('set null');
});
// Supplier Invoices (Factures Fournisseurs)
Schema::create('supplier_invoices', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('fournisseur_id');
$table->unsignedBigInteger('purchase_order_id')->nullable(); // Optional link to purchase order
$table->string('invoice_number', 191);
$table->date('invoice_date')->useCurrent();
$table->date('due_date')->nullable();
$table->enum('status', ['brouillon', 'en_attente', 'payee', 'annulee'])->default('brouillon');
$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->text('notes')->nullable();
$table->timestamps();
$table->unique(['fournisseur_id', 'invoice_number'], 'uq_supplier_invoice');
$table->index('status', 'idx_si_status');
$table->index('invoice_date', 'idx_si_invoice_date');
$table->foreign('fournisseur_id', 'fk_si_fournisseur')->references('id')->on('fournisseurs');
$table->foreign('purchase_order_id', 'fk_si_po')->references('id')->on('purchase_orders')->onDelete('set null');
});
// Supplier Invoice Lines
Schema::create('supplier_invoice_lines', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('supplier_invoice_id');
$table->unsignedBigInteger('product_id')->nullable();
$table->unsignedBigInteger('purchase_order_line_id')->nullable(); // Link to original order line
$table->text('description');
$table->decimal('quantity', 14, 3)->default(1);
$table->decimal('unit_price', 12, 2);
$table->decimal('tva_rate', 5, 2)->default(0);
$table->decimal('total_ht', 14, 2);
$table->timestamps();
$table->foreign('supplier_invoice_id', 'fk_sil_si')->references('id')->on('supplier_invoices')->onDelete('cascade');
$table->foreign('product_id', 'fk_sil_product')->references('id')->on('products')->onDelete('set null');
$table->foreign('purchase_order_line_id', 'fk_sil_pol')->references('id')->on('purchase_order_lines')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('supplier_invoice_lines');
Schema::dropIfExists('supplier_invoices');
Schema::dropIfExists('goods_receipt_lines');
Schema::dropIfExists('goods_receipts');
Schema::dropIfExists('purchase_order_lines');
Schema::dropIfExists('purchase_orders');
}
};

View File

@ -80,6 +80,9 @@ Route::middleware('auth:sanctum')->group(function () {
Route::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']); Route::post('/invoices/from-quote/{quoteId}', [\App\Http\Controllers\Api\InvoiceController::class, 'createFromQuote']);
Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class); Route::apiResource('invoices', \App\Http\Controllers\Api\InvoiceController::class);
// Avoir management
Route::apiResource('avoirs', \App\Http\Controllers\Api\AvoirController::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);

View File

@ -0,0 +1,91 @@
<template>
<div v-if="isLoading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<facture-fournisseur-detail-template v-else-if="currentFacture">
<template #header>
<facture-fournisseur-header
:facture-number="currentFacture.number"
:date="currentFacture.date"
/>
</template>
<template #lines>
<facture-fournisseur-lines-table :lines="currentFacture.lines" />
</template>
<template #billing>
<div>
<h6 class="mb-3 text-sm">Informations Fournisseur</h6>
<p class="text-sm mb-1">
<strong>Nom:</strong> {{ currentFacture.supplierName }}
</p>
<p class="text-sm mb-1">
<strong>Statut:</strong> {{ getStatusLabel(currentFacture.status) }}
</p>
</div>
</template>
<template #summary>
<facture-fournisseur-summary
:ht="currentFacture.totalHt"
:tva="currentFacture.totalTva"
:ttc="currentFacture.totalTtc"
/>
</template>
<template #actions>
<div class="d-flex justify-content-end gap-2">
<soft-button color="secondary" variant="outline" size="sm" @click="handleBack">
Retour
</soft-button>
<soft-button color="info" size="sm">
<i class="fas fa-print me-1"></i> Imprimer
</soft-button>
</div>
</template>
</facture-fournisseur-detail-template>
</template>
<script setup>
import { onMounted, defineProps } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import FactureFournisseurDetailTemplate from "@/components/templates/FactureFournisseur/FactureFournisseurDetailTemplate.vue";
import FactureFournisseurHeader from "@/components/molecules/FactureFournisseur/FactureFournisseurHeader.vue";
import FactureFournisseurLinesTable from "@/components/molecules/FactureFournisseur/FactureFournisseurLinesTable.vue";
import FactureFournisseurSummary from "@/components/molecules/FactureFournisseur/FactureFournisseurSummary.vue";
import SoftButton from "@/components/SoftButton.vue";
import { useFactureFournisseurStore } from "@/stores/factureFournisseurStore";
const props = defineProps({
id: {
type: [String, Number],
required: true,
},
});
const router = useRouter();
const factureStore = useFactureFournisseurStore();
const { currentFacture, isLoading } = storeToRefs(factureStore);
const handleBack = () => {
router.push("/fournisseurs/factures");
};
const getStatusLabel = (status) => {
const labels = {
payee: "Payée",
en_attente: "En attente",
annulee: "Annulée",
brouillon: "Brouillon",
};
return labels[status] || status;
};
onMounted(() => {
factureStore.fetchFacture(Number(props.id));
});
</script>

View File

@ -0,0 +1,60 @@
<template>
<div class="container-fluid py-4">
<facture-fournisseur-list-controls
@create="handleCreate"
@filter="handleFilter"
/>
<div class="row">
<div class="col-12">
<facture-fournisseur-table
:data="filteredFactures"
:loading="isLoading"
@view="handleView"
@delete="handleDelete"
/>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import FactureFournisseurListControls from "@/components/molecules/FactureFournisseur/FactureFournisseurListControls.vue";
import FactureFournisseurTable from "@/components/molecules/Tables/Fournisseurs/FactureFournisseurTable.vue";
import { useFactureFournisseurStore } from "@/stores/factureFournisseurStore";
const router = useRouter();
const factureStore = useFactureFournisseurStore();
const { factures, isLoading } = storeToRefs(factureStore);
const currentFilter = ref(null);
const filteredFactures = computed(() => {
if (!currentFilter.value) return factures.value;
return factures.value.filter(f => f.status === currentFilter.value);
});
const handleCreate = () => {
router.push("/fournisseurs/factures/new");
};
const handleView = (id) => {
router.push(`/fournisseurs/factures/${id}`);
};
const handleDelete = async (id) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette facture ?")) {
await factureStore.deleteFacture(id);
}
};
const handleFilter = (status) => {
currentFilter.value = status;
};
onMounted(() => {
factureStore.fetchFactures();
});
</script>

View File

@ -0,0 +1,42 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-9 col-12 mx-auto">
<div class="card">
<div class="card-header pb-0">
<h5 class="mb-0">Nouvelle Facture Fournisseur</h5>
<p class="text-sm mb-0">Saisissez les informations de la facture reçue.</p>
</div>
<div class="card-body">
<new-facture-fournisseur-form
@submit="handleSubmit"
@cancel="handleCancel"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import NewFactureFournisseurForm from "@/components/molecules/FactureFournisseur/NewFactureFournisseurForm.vue";
import { useFactureFournisseurStore } from "@/stores/factureFournisseurStore";
const router = useRouter();
const factureStore = useFactureFournisseurStore();
const handleSubmit = async (payload) => {
try {
await factureStore.createFacture(payload);
router.push("/fournisseurs/factures");
} catch (error) {
alert("Erreur lors de la création de la facture");
}
};
const handleCancel = () => {
router.push("/fournisseurs/factures");
};
</script>

View File

@ -0,0 +1,36 @@
<template>
<div class="row justify-content-between">
<div class="col-md-4 text-left">
<h6 class="mb-0 text-dark font-weight-bold">Facture Fournisseur</h6>
<p class="text-secondary text-sm">{{ factureNumber }}</p>
</div>
<div class="col-md-4 text-end">
<h6 class="mb-0 text-dark font-weight-bold">Date d'émission</h6>
<p class="text-secondary text-sm">{{ formatDate(date) }}</p>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
factureNumber: {
type: String,
required: true,
},
date: {
type: [String, Date],
required: true,
},
});
const formatDate = (dateString) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
});
};
</script>

View File

@ -0,0 +1,61 @@
<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 ps-2">
Description
</th>
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
Qté
</th>
<th class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
Prix Unit. HT
</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 in lines" :key="line.id">
<td>
<div class="d-flex px-2 py-1">
<div class="d-flex flex-column justify-content-center">
<h6 class="mb-0 text-sm">{{ line.designation }}</h6>
</div>
</div>
</td>
<td class="align-middle text-center text-sm">
<span class="text-secondary text-xs font-weight-bold">{{ line.quantity }}</span>
</td>
<td class="align-middle text-center text-sm">
<span class="text-secondary text-xs font-weight-bold">{{ formatCurrency(line.priceHt) }}</span>
</td>
<td class="align-middle text-center text-sm">
<span class="text-secondary text-xs font-weight-bold">{{ formatCurrency(line.totalHt) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
lines: {
type: Array,
required: true,
default: () => [],
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="d-sm-flex justify-content-between">
<div>
<soft-button color="success" variant="gradient" size="sm" @click="$emit('create')">
<i class="fas fa-plus me-1"></i> Ajouter une facture
</soft-button>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<soft-button
id="factureFilterDropdown"
color="dark"
variant="outline"
size="sm"
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="factureFilterDropdown"
>
<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', 'en_attente')"
>
Statut: En attente
</a>
</li>
<li>
<a
class="dropdown-item border-radius-md"
href="javascript:;"
@click="$emit('filter', 'brouillon')"
>
Statut: Brouillon
</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"
size="sm"
@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";
defineEmits(["create", "filter", "export"]);
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="row mt-4">
<div class="col-md-6 ms-auto">
<div class="table-responsive">
<table class="table align-items-center mb-0">
<tbody>
<tr>
<td>
<span class="text-sm">Total HT:</span>
</td>
<td class="text-end">
<span class="text-sm text-dark font-weight-bold">{{ formatCurrency(ht) }}</span>
</td>
</tr>
<tr>
<td>
<span class="text-sm">TVA (20%):</span>
</td>
<td class="text-end">
<span class="text-sm text-dark font-weight-bold">{{ formatCurrency(tva) }}</span>
</td>
</tr>
<tr>
<td>
<span class="text-sm font-weight-bold text-info">Total TTC:</span>
</td>
<td class="text-end">
<span class="text-sm font-weight-bold text-info">{{ formatCurrency(ttc) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
ht: {
type: Number,
required: true,
},
tva: {
type: Number,
required: true,
},
ttc: {
type: Number,
required: true,
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
</script>

View File

@ -0,0 +1,305 @@
<template>
<form @submit.prevent="submitForm">
<!-- Row 1: N° Facture, Fournisseur, Date -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label">N° Facture Fournisseur *</label>
<soft-input
v-model="formData.number"
type="text"
placeholder="Ex: FR-2026-001"
required
/>
</div>
<div class="col-md-4">
<label class="form-label">Fournisseur *</label>
<select v-model="formData.supplierId" class="form-select" @change="updateSupplierInfo" required>
<option value="">-- Sélectionner un fournisseur --</option>
<option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">
{{ supplier.name }}
</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Date facture *</label>
<soft-input
v-model="formData.date"
type="date"
required
/>
</div>
</div>
<!-- Row 2: Statut, Notes -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Statut</label>
<select v-model="formData.status" class="form-select">
<option value="brouillon">Brouillon</option>
<option value="en_attente">En attente de paiement</option>
<option value="payee">Payée</option>
<option value="annulee">Annulée</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control"
placeholder="Remarques éventuelles..."
rows="2"
></textarea>
</div>
</div>
<!-- Articles Section -->
<div class="mb-4">
<div class="flex items-center justify-between mb-4">
<label class="peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-lg font-bold">Lignes de facture</label>
<button
class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-8 rounded-md px-3 text-xs"
type="button"
@click="addLine"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus w-4 h-4 mr-2">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
Ajouter ligne
</button>
</div>
<div class="space-y-3 mt-3">
<div
v-for="(line, index) in formData.lines"
:key="index"
class="row g-2 align-items-end bg-light p-3 rounded mx-0"
>
<div class="col-md-5">
<label class="form-label text-xs mb-1">Désignation *</label>
<soft-input
v-model="line.designation"
type="text"
placeholder="Nom de l'article ou prestation"
required
/>
</div>
<div class="col-md-2">
<label class="form-label text-xs mb-1">Quantité *</label>
<soft-input
v-model.number="line.quantity"
type="number"
placeholder="Qté"
:min="1"
required
/>
</div>
<div class="col-md-2">
<label class="form-label text-xs mb-1">Prix HT *</label>
<soft-input
v-model.number="line.priceHt"
type="number"
placeholder="0.00"
step="0.01"
required
/>
</div>
<div class="col-md-2 text-end">
<label class="form-label text-xs mb-1 d-block">Sous-total HT</label>
<span class="text-sm font-weight-bold">
{{ formatCurrency(line.quantity * line.priceHt) }}
</span>
</div>
<div class="col-md-1 text-end">
<button
type="button"
class="btn btn-sm btn-link text-danger mb-0"
@click="removeLine(index)"
:disabled="formData.lines.length === 1"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Totaux -->
<div class="row mb-4">
<div class="col-md-5 ms-auto">
<div class="card bg-gray-100 shadow-none border">
<div class="card-body p-3">
<div class="d-flex justify-content-between mb-2">
<span class="text-sm">Total HT:</span>
<span class="text-sm font-weight-bold">{{ formatCurrency(calculateTotalHt()) }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-sm">TVA (20%):</span>
<span class="text-sm font-weight-bold">{{ formatCurrency(calculateTotalTva()) }}</span>
</div>
<hr class="horizontal dark my-2">
<div class="d-flex justify-content-between">
<span class="text-base font-weight-bold">Total TTC:</span>
<span class="text-base font-weight-bold text-info">{{ formatCurrency(calculateTotalTtc()) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Boutons d'action -->
<div class="d-flex justify-content-end gap-3 mt-4">
<soft-button
type="button"
color="secondary"
variant="outline"
@click="$emit('cancel')"
>
Annuler
</soft-button>
<soft-button
type="submit"
color="success"
>
Enregistrer la facture
</soft-button>
</div>
</form>
</template>
<script setup>
import { ref, defineEmits } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
const emit = defineEmits(["submit", "cancel"]);
const suppliers = [
{ id: "1", name: "Produits Funéraires Pro" },
{ id: "2", name: "Thanatos Supply" },
{ id: "3", name: "ISOFROID" },
{ id: "4", name: "EEP Co EUROPE" },
{ id: "5", name: "NEXTECH MEDICAL" },
{ id: "6", name: "ACTION" },
{ id: "7", name: "E.LECLERC" },
];
const formData = ref({
number: "",
supplierId: "",
supplierName: "",
date: new Date().toISOString().split("T")[0],
status: "brouillon",
notes: "",
lines: [
{
designation: "",
quantity: 1,
priceHt: 0,
},
],
});
const updateSupplierInfo = () => {
const supplier = suppliers.find(s => s.id === formData.value.supplierId);
if (supplier) {
formData.value.supplierName = supplier.name;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
const calculateTotalHt = () => {
return formData.value.lines.reduce((sum, line) => {
return sum + line.quantity * (line.priceHt || 0);
}, 0);
};
const calculateTotalTva = () => {
return calculateTotalHt() * 0.2;
};
const calculateTotalTtc = () => {
return calculateTotalHt() + calculateTotalTva();
};
const addLine = () => {
formData.value.lines.push({
designation: "",
quantity: 1,
priceHt: 0,
});
};
const removeLine = (index) => {
if (formData.value.lines.length > 1) {
formData.value.lines.splice(index, 1);
}
};
const submitForm = () => {
if (!formData.value.supplierId) {
alert("Veuillez sélectionner un fournisseur");
return;
}
const payload = {
...formData.value,
totalHt: calculateTotalHt(),
totalTva: calculateTotalTva(),
totalTtc: calculateTotalTtc(),
// Map lines to include totalHt for store consistency
lines: formData.value.lines.map(line => ({
...line,
totalHt: line.quantity * line.priceHt
}))
};
emit("submit", payload);
};
</script>
<style scoped>
/* Scoped styles for the custom button from user request */
.bg-primary {
background-color: var(--bs-primary, #cb0c9f);
}
.text-primary-foreground {
color: #fff;
}
.bg-primary\:hover {
background-color: #b30b8c;
}
/* Tailwind-like utilities used in the provided snippet */
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.mb-4 { margin-bottom: 1.5rem; }
.text-lg { font-size: 1.125rem; }
.font-bold { font-weight: 700; }
.gap-2 { gap: 0.5rem; }
.whitespace-nowrap { white-space: nowrap; }
.transition-colors { transition-property: background-color, border-color, color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.shadow { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); }
.rounded-md { border-radius: 0.375rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.h-8 { height: 2rem; }
.text-xs { font-size: 0.75rem; }
.font-medium { font-weight: 500; }
.space-y-3 > div + div {
margin-top: 1rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
</style>

View File

@ -0,0 +1,218 @@
<template>
<div class="card mt-4">
<div class="table-responsive">
<table id="facture-fournisseur-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>N° Facture</th>
<th>Fournisseur</th>
<th>Date</th>
<th>Montant TTC</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="facture in data" :key="facture.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">
{{ facture.number }}
</p>
</div>
</td>
<!-- Supplier -->
<td class="text-xs font-weight-bold">
<span>{{ facture.supplierName }}</span>
</td>
<!-- Date -->
<td class="font-weight-bold">
<span class="my-2 text-xs">{{ formatDate(facture.date) }}</span>
</td>
<!-- Total TTC -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ formatCurrency(facture.totalTtc) }}</span>
</td>
<!-- Status -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getStatusColor(facture.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(facture.status)" aria-hidden="true"></i>
</soft-button>
<span>{{ getStatusLabel(facture.status) }}</span>
</div>
</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="facture.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="facture.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 SoftCheckbox from "@/components/SoftCheckbox.vue";
const emit = defineEmits(["view", "delete"]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const dataTableInstance = ref(null);
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 getStatusColor = (status) => {
const map = {
payee: "success",
en_attente: "warning",
annulee: "danger",
brouillon: "secondary",
};
return map[status] || "secondary";
};
const getStatusIcon = (status) => {
const map = {
payee: "fas fa-check",
en_attente: "fas fa-hourglass-half",
annulee: "fas fa-ban",
brouillon: "fas fa-pen",
};
return map[status] || "fas fa-info";
};
const getStatusLabel = (status) => {
const labels = {
payee: "Payée",
en_attente: "En attente",
annulee: "Annulée",
brouillon: "Brouillon",
};
return labels[status] || status;
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("facture-fournisseur-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("facture-fournisseur-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

@ -45,13 +45,13 @@
:is-active="activeTab === 'notes'" :is-active="activeTab === 'notes'"
@click="$emit('change-tab', 'notes')" @click="$emit('change-tab', 'notes')"
/> />
<TabNavigationItem <!-- <TabNavigationItem
icon="fas fa-sitemap" icon="fas fa-sitemap"
label="Sous-comptes" label="Sous-comptes"
:is-active="activeTab === 'children'" :is-active="activeTab === 'children'"
:badge="childrenCount > 0 ? childrenCount : null" :badge="childrenCount > 0 ? childrenCount : null"
@click="$emit('change-tab', 'children')" @click="$emit('change-tab', 'children')"
/> /> -->
<TabNavigationItem <TabNavigationItem
icon="fas fa-file-invoice-dollar" icon="fas fa-file-invoice-dollar"
label="Avoirs" label="Avoirs"

View File

@ -0,0 +1,35 @@
<template>
<div class="row">
<div class="col-lg-8 col-md-10 mx-auto">
<div class="card my-4">
<div class="card-header p-3 pb-0">
<slot name="header"></slot>
</div>
<hr class="horizontal dark my-3">
<div class="card-body p-3 pt-0">
<div class="row">
<div class="col-12">
<slot name="lines"></slot>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<slot name="billing"></slot>
</div>
<div class="col-md-6 text-end">
<slot name="summary"></slot>
</div>
</div>
<hr class="horizontal dark mt-4 mb-3">
<div class="row">
<div class="col-12">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup></script>

View File

@ -0,0 +1,18 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="actions"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="mt-4">
<slot name="table"></slot>
</div>
</div>
</div>
</div>
</template>
<script setup></script>

View File

@ -7,7 +7,6 @@
class="nav-link" class="nav-link"
v-bind="$attrs" v-bind="$attrs"
type="button" type="button"
@click="isExpanded = !isExpanded"
> >
<div <div
class="text-center bg-white shadow icon icon-shape icon-sm border-radius-md d-flex align-items-center justify-content-center" class="text-center bg-white shadow icon icon-shape icon-sm border-radius-md d-flex align-items-center justify-content-center"

View File

@ -1,7 +1,8 @@
<template> <template>
<div <div
id="sidenav-collapse-main" id="sidenav-collapse-main"
class="w-auto h-auto collapse navbar-collapse max-height-vh-100 h-100" class="w-auto h-auto collapse navbar-collapse h-100"
style="overflow: visible"
> >
<ul class="navbar-nav"> <ul class="navbar-nav">
<!-- Render navigation items from data array --> <!-- Render navigation items from data array -->
@ -214,7 +215,7 @@ export default {
id: "fournisseurs-invoices", id: "fournisseurs-invoices",
route: { name: "Factures fournisseurs" }, route: { name: "Factures fournisseurs" },
miniIcon: "F", miniIcon: "F",
text: "Factures", text: "Facture fournisseur",
}, },
{ {
id: "fournisseurs-stats", id: "fournisseurs-stats",

View File

@ -1,7 +1,7 @@
<template> <template>
<aside <aside
id="sidenav-main" id="sidenav-main"
class="my-3 overflow-auto border-0 sidenav navbar navbar-vertical navbar-expand-xs border-radius-xl" class="my-3 overflow-y-auto border-0 sidenav navbar navbar-vertical navbar-expand-xs border-radius-xl"
:class="isRTL ? 'me-3 rotate-caret' : 'ms-3'" :class="isRTL ? 'me-3 rotate-caret' : 'ms-3'"
> >
<div class="sidenav-header"> <div class="sidenav-header">

View File

@ -471,7 +471,17 @@ const routes = [
{ {
path: "/fournisseurs/factures", path: "/fournisseurs/factures",
name: "Factures fournisseurs", name: "Factures fournisseurs",
component: () => import("@/views/pages/Fournisseurs/Factures.vue"), component: () => import("@/views/pages/Fournisseurs/FactureFournisseurList.vue"),
},
{
path: "/fournisseurs/factures/new",
name: "Nouvelle Facture Fournisseur",
component: () => import("@/views/pages/Fournisseurs/NewFactureFournisseur.vue"),
},
{
path: "/fournisseurs/factures/:id",
name: "Facture Fournisseur Details",
component: () => import("@/views/pages/Fournisseurs/FactureFournisseurDetail.vue"),
}, },
{ {
path: "/fournisseurs/statistiques", path: "/fournisseurs/statistiques",

View File

@ -0,0 +1,109 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export interface FactureFournisseurLine {
id?: number;
designation: string;
quantity: number;
priceHt: number;
totalHt: number;
}
export interface FactureFournisseur {
id: number;
number: string;
supplierId: string;
supplierName: string;
date: string;
status: string;
totalHt: number;
totalTva: number;
totalTtc: number;
lines: FactureFournisseurLine[];
}
export const useFactureFournisseurStore = defineStore("factureFournisseur", () => {
// State
const factures = ref<FactureFournisseur[]>([
{
id: 1,
number: "FF-2026-0001",
supplierId: "1",
supplierName: "Produits Funéraires Pro",
date: "2026-01-15",
status: "payee",
totalHt: 1250.0,
totalTva: 250.0,
totalTtc: 1500.0,
lines: [
{ id: 1, designation: "Cercueils Chêne Prestige", quantity: 2, priceHt: 625.0, totalHt: 1250.0 }
]
},
{
id: 2,
number: "FF-2026-0002",
supplierId: "2",
supplierName: "Thanatos Supply",
date: "2026-01-20",
status: "en_attente",
totalHt: 450.0,
totalTva: 90.0,
totalTtc: 540.0,
lines: [
{ id: 2, designation: "Urnes Granit Noir", quantity: 5, priceHt: 90.0, totalHt: 450.0 }
]
}
]);
const currentFacture = ref<FactureFournisseur | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const allFactures = computed(() => factures.value);
const isLoading = computed(() => loading.value);
// Actions
const fetchFactures = async () => {
loading.value = true;
// Mock API call
await new Promise(resolve => setTimeout(resolve, 500));
loading.value = false;
};
const fetchFacture = async (id: number) => {
loading.value = true;
await new Promise(resolve => setTimeout(resolve, 300));
const found = factures.value.find(f => f.id === id);
currentFacture.value = found || null;
loading.value = false;
};
const createFacture = async (payload: any) => {
loading.value = true;
await new Promise(resolve => setTimeout(resolve, 800));
const newFacture = {
...payload,
id: factures.value.length + 1,
};
factures.value.push(newFacture);
loading.value = false;
return newFacture;
};
const deleteFacture = async (id: number) => {
factures.value = factures.value.filter(f => f.id !== id);
};
return {
factures,
currentFacture,
loading,
error,
allFactures,
isLoading,
fetchFactures,
fetchFacture,
createFacture,
deleteFacture
};
});

View File

@ -0,0 +1,7 @@
<template>
<facture-fournisseur-detail-presentation :id="$route.params.id" />
</template>
<script setup>
import FactureFournisseurDetailPresentation from "@/components/Organism/FactureFournisseur/FactureFournisseurDetailPresentation.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<facture-fournisseur-list-presentation />
</template>
<script setup>
import FactureFournisseurListPresentation from "@/components/Organism/FactureFournisseur/FactureFournisseurListPresentation.vue";
</script>

View File

@ -0,0 +1,7 @@
<template>
<new-facture-fournisseur-presentation />
</template>
<script setup>
import NewFactureFournisseurPresentation from "@/components/Organism/FactureFournisseur/NewFactureFournisseurPresentation.vue";
</script>