Compare commits
No commits in common. "d911435b5c2e611eb2b836454be4bbcf6c538eae" and "5d93f9d39a98abaf544d650e2085ab9e279ec9ab" have entirely different histories.
d911435b5c
...
5d93f9d39a
@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\StoreQuoteRequest;
|
|
||||||
use App\Http\Requests\UpdateQuoteRequest;
|
|
||||||
use App\Http\Resources\QuoteResource;
|
|
||||||
use App\Models\Quote;
|
|
||||||
use App\Repositories\QuoteRepositoryInterface;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class QuoteController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected QuoteRepositoryInterface $quoteRepository
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a listing of quotes.
|
|
||||||
*/
|
|
||||||
public function index(): AnonymousResourceCollection|JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$quotes = $this->quoteRepository->all();
|
|
||||||
return QuoteResource::collection($quotes);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error fetching quotes: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Une erreur est survenue lors de la récupération des devis.',
|
|
||||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created quote.
|
|
||||||
*/
|
|
||||||
public function store(StoreQuoteRequest $request): QuoteResource|JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$quote = $this->quoteRepository->create($request->validated());
|
|
||||||
return new QuoteResource($quote);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error creating quote: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
'data' => $request->validated(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Une erreur est survenue lors de la création du devis.',
|
|
||||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified quote.
|
|
||||||
*/
|
|
||||||
public function show(string $id): QuoteResource|JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$quote = $this->quoteRepository->find($id);
|
|
||||||
|
|
||||||
if (! $quote) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Devis non trouvé.',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new QuoteResource($quote);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error fetching quote: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
'quote_id' => $id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Une erreur est survenue lors de la récupération du devis.',
|
|
||||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the specified quote.
|
|
||||||
*/
|
|
||||||
public function update(UpdateQuoteRequest $request, string $id): QuoteResource|JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$updated = $this->quoteRepository->update($id, $request->validated());
|
|
||||||
|
|
||||||
if (! $updated) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Devis non trouvé ou échec de la mise à jour.',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$quote = $this->quoteRepository->find($id);
|
|
||||||
return new QuoteResource($quote);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error updating quote: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
'quote_id' => $id,
|
|
||||||
'data' => $request->validated(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Une erreur est survenue lors de la mise à jour du devis.',
|
|
||||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the specified quote.
|
|
||||||
*/
|
|
||||||
public function destroy(string $id): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$deleted = $this->quoteRepository->delete($id);
|
|
||||||
|
|
||||||
if (! $deleted) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Devis non trouvé ou échec de la suppression.',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Devis supprimé avec succès.',
|
|
||||||
], 200);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error deleting quote: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
'quote_id' => $id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Une erreur est survenue lors de la suppression du devis.',
|
|
||||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreQuoteLineRequest 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 [
|
|
||||||
'quote_id' => 'required|exists:quotes,id',
|
|
||||||
'product_id' => 'nullable|exists:products,id',
|
|
||||||
'packaging_id' => 'nullable|exists:product_packagings,id',
|
|
||||||
'packages_qty' => 'nullable|numeric|min:0',
|
|
||||||
'units_qty' => 'nullable|numeric|min:0',
|
|
||||||
'description' => 'required|string',
|
|
||||||
'qty_base' => 'nullable|numeric|min:0',
|
|
||||||
'unit_price' => 'required|numeric|min:0',
|
|
||||||
'unit_price_per_package' => 'nullable|numeric|min:0',
|
|
||||||
'tva_rate_id' => 'nullable|exists:tva_rates,id',
|
|
||||||
'discount_pct' => 'required|numeric|min:0|max:100',
|
|
||||||
'total_ht' => 'required|numeric|min:0',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreQuoteRequest 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',
|
|
||||||
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
|
|
||||||
'quote_date' => 'required|date',
|
|
||||||
'valid_until' => 'nullable|date|after_or_equal:quote_date',
|
|
||||||
'currency' => 'required|string|size:3',
|
|
||||||
'total_ht' => 'required|numeric|min:0',
|
|
||||||
'total_tva' => 'required|numeric|min:0',
|
|
||||||
'total_ttc' => 'required|numeric|min:0',
|
|
||||||
'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.',
|
|
||||||
'quote_date.required' => 'La date du devis est obligatoire.',
|
|
||||||
'quote_date.date' => 'La date du devis n\'est pas valide.',
|
|
||||||
'valid_until.date' => 'La date de validité n\'est pas valide.',
|
|
||||||
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
|
|
||||||
'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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateQuoteLineRequest 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 [
|
|
||||||
'quote_id' => 'sometimes|exists:quotes,id',
|
|
||||||
'product_id' => 'nullable|exists:products,id',
|
|
||||||
'packaging_id' => 'nullable|exists:product_packagings,id',
|
|
||||||
'packages_qty' => 'nullable|numeric|min:0',
|
|
||||||
'units_qty' => 'nullable|numeric|min:0',
|
|
||||||
'description' => 'sometimes|string',
|
|
||||||
'qty_base' => 'nullable|numeric|min:0',
|
|
||||||
'unit_price' => 'sometimes|numeric|min:0',
|
|
||||||
'unit_price_per_package' => 'nullable|numeric|min:0',
|
|
||||||
'tva_rate_id' => 'nullable|exists:tva_rates,id',
|
|
||||||
'discount_pct' => 'sometimes|numeric|min:0|max:100',
|
|
||||||
'total_ht' => 'sometimes|numeric|min:0',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateQuoteRequest 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' => 'sometimes|exists:clients,id',
|
|
||||||
'group_id' => 'nullable|exists:client_groups,id',
|
|
||||||
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $this->quote->id,
|
|
||||||
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
|
|
||||||
'quote_date' => 'sometimes|date',
|
|
||||||
'valid_until' => 'nullable|date|after_or_equal:quote_date',
|
|
||||||
'currency' => 'sometimes|string|size:3',
|
|
||||||
'total_ht' => 'sometimes|numeric|min:0',
|
|
||||||
'total_tva' => 'sometimes|numeric|min:0',
|
|
||||||
'total_ttc' => 'sometimes|numeric|min:0',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'client_id.exists' => 'Le client sélectionné est invalide.',
|
|
||||||
'group_id.exists' => 'Le groupe sélectionné est invalide.',
|
|
||||||
'reference.string' => 'Le numéro de devis doit être une chaîne de caractères.',
|
|
||||||
'reference.max' => 'Le numéro de devis ne doit pas dépasser 191 caractères.',
|
|
||||||
'reference.unique' => 'Ce numéro de devis existe déjà.',
|
|
||||||
'status.in' => 'Le statut sélectionné est invalide.',
|
|
||||||
'quote_date.date' => 'La date du devis n\'est pas valide.',
|
|
||||||
'valid_until.date' => 'La date de validité n\'est pas valide.',
|
|
||||||
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
|
|
||||||
'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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class QuoteLineResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Transform the resource into an array.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'quote_id' => $this->quote_id,
|
|
||||||
'product_id' => $this->product_id,
|
|
||||||
'product_name' => $this->product ? $this->product->nom : null, // Assuming 'nom' is the name field
|
|
||||||
'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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class QuoteResource 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,
|
|
||||||
'reference' => $this->reference,
|
|
||||||
'status' => $this->status,
|
|
||||||
'quote_date' => $this->quote_date,
|
|
||||||
'valid_until' => $this->valid_until,
|
|
||||||
'currency' => $this->currency,
|
|
||||||
'total_ht' => $this->total_ht,
|
|
||||||
'total_tva' => $this->total_tva,
|
|
||||||
'total_ttc' => $this->total_ttc,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
'client' => $this->whenLoaded('client'),
|
|
||||||
'group' => $this->whenLoaded('group'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class Quote extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'client_id',
|
|
||||||
'group_id',
|
|
||||||
'reference',
|
|
||||||
'status',
|
|
||||||
'quote_date',
|
|
||||||
'valid_until',
|
|
||||||
'currency',
|
|
||||||
'total_ht',
|
|
||||||
'total_tva',
|
|
||||||
'total_ttc',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected static function booted()
|
|
||||||
{
|
|
||||||
static::creating(function ($quote) {
|
|
||||||
$prefix = 'DEV-' . now()->format('Ym') . '-';
|
|
||||||
$lastQuote = self::where('reference', 'like', $prefix . '%')
|
|
||||||
->orderBy('reference', 'desc')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($lastQuote) {
|
|
||||||
$lastNumber = intval(substr($lastQuote->reference, -4));
|
|
||||||
$newNumber = $lastNumber + 1;
|
|
||||||
} else {
|
|
||||||
$newNumber = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$quote->reference = $prefix . str_pad((string)$newNumber, 4, '0', STR_PAD_LEFT);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'quote_date' => 'date',
|
|
||||||
'valid_until' => '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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class QuoteLine extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'quote_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 quote()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Quote::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function product()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function packaging()
|
|
||||||
{
|
|
||||||
// Assuming ProductPackaging model exists
|
|
||||||
return $this->belongsTo(\App\Models\Stock\ProductPackaging::class, 'packaging_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tvaRate()
|
|
||||||
{
|
|
||||||
// Assuming TvaRate model exists
|
|
||||||
return $this->belongsTo(\App\Models\TvaRate::class, 'tva_rate_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -66,10 +66,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(\App\Repositories\FileRepositoryInterface::class, \App\Repositories\FileRepository::class);
|
$this->app->bind(\App\Repositories\FileRepositoryInterface::class, \App\Repositories\FileRepository::class);
|
||||||
|
|
||||||
$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\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
|
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -23,8 +23,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Repositories;
|
|
||||||
|
|
||||||
use App\Models\QuoteLine;
|
|
||||||
|
|
||||||
class QuoteLineRepository implements QuoteLineRepositoryInterface
|
|
||||||
{
|
|
||||||
public function create(array $data): QuoteLine
|
|
||||||
{
|
|
||||||
return QuoteLine::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): ?QuoteLine
|
|
||||||
{
|
|
||||||
return QuoteLine::find($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getByQuoteId(string $quoteId)
|
|
||||||
{
|
|
||||||
return QuoteLine::where('quote_id', $quoteId)->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Repositories;
|
|
||||||
|
|
||||||
use App\Models\QuoteLine;
|
|
||||||
|
|
||||||
interface QuoteLineRepositoryInterface
|
|
||||||
{
|
|
||||||
public function create(array $data): QuoteLine;
|
|
||||||
public function update(string $id, array $data): bool;
|
|
||||||
public function delete(string $id): bool;
|
|
||||||
public function find(string $id): ?QuoteLine;
|
|
||||||
public function getByQuoteId(string $quoteId);
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repositories;
|
|
||||||
|
|
||||||
use App\Models\Quote;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
Quote $model,
|
|
||||||
protected QuoteLineRepositoryInterface $quoteLineRepository
|
|
||||||
) {
|
|
||||||
parent::__construct($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(array $data): Quote
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($data) {
|
|
||||||
try {
|
|
||||||
// Create the quote
|
|
||||||
$quote = parent::create($data);
|
|
||||||
|
|
||||||
// Create the quote lines
|
|
||||||
if (isset($data['lines']) && is_array($data['lines'])) {
|
|
||||||
foreach ($data['lines'] as $lineData) {
|
|
||||||
$lineData['quote_id'] = $quote->id;
|
|
||||||
$this->quoteLineRepository->create($lineData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $quote;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Log the error
|
|
||||||
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Re-throw to trigger rollback
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repositories;
|
|
||||||
|
|
||||||
interface QuoteRepositoryInterface extends BaseRepositoryInterface
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<?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('quotes', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->unsignedBigInteger('client_id');
|
|
||||||
$table->unsignedBigInteger('group_id')->nullable();
|
|
||||||
$table->string('reference', 191);
|
|
||||||
$table->enum('status', ['brouillon', 'envoye', 'accepte', 'refuse', 'expire', 'annule'])->default('brouillon')->index('idx_quotes_status');
|
|
||||||
$table->date('quote_date')->default(now());
|
|
||||||
$table->date('valid_until')->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->timestamps();
|
|
||||||
|
|
||||||
$table->foreign('client_id', 'fk_quotes_client')->references('id')->on('clients');
|
|
||||||
$table->foreign('group_id', 'fk_quotes_group')->references('id')->on('client_groups')->onDelete('set null');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('quotes');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
<?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('quote_lines', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('quote_id')->constrained('quotes')->onDelete('cascade');
|
|
||||||
$table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null');
|
|
||||||
$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();
|
|
||||||
|
|
||||||
// Tables do not exist yet, removing constraints
|
|
||||||
$table->foreignId('packaging_id')->nullable();
|
|
||||||
$table->foreignId('tva_rate_id')->nullable();
|
|
||||||
|
|
||||||
$table->decimal('discount_pct', 5, 2)->default(0);
|
|
||||||
$table->decimal('total_ht', 14, 2);
|
|
||||||
|
|
||||||
// If timestamps are needed (default model usually has them)
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('quote_lines');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -18,7 +18,6 @@ use App\Http\Controllers\Api\DeceasedDocumentController;
|
|||||||
use App\Http\Controllers\Api\InterventionController;
|
use App\Http\Controllers\Api\InterventionController;
|
||||||
use App\Http\Controllers\Api\FileController;
|
use App\Http\Controllers\Api\FileController;
|
||||||
use App\Http\Controllers\Api\FileAttachmentController;
|
use App\Http\Controllers\Api\FileAttachmentController;
|
||||||
use App\Http\Controllers\Api\QuoteController;
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -62,9 +61,6 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
|
|
||||||
Route::apiResource('client-categories', ClientCategoryController::class);
|
Route::apiResource('client-categories', ClientCategoryController::class);
|
||||||
|
|
||||||
// Quote management
|
|
||||||
Route::apiResource('quotes', QuoteController::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);
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<create-quote-template>
|
|
||||||
<template #actions>
|
|
||||||
<soft-button
|
|
||||||
color="secondary"
|
|
||||||
variant="outline"
|
|
||||||
class="me-2"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</soft-button>
|
|
||||||
<soft-button
|
|
||||||
color="primary"
|
|
||||||
variant="gradient"
|
|
||||||
@click="saveQuote"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? 'Enregistrement...' : 'Enregistrer' }}
|
|
||||||
</soft-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #client-selection>
|
|
||||||
<label>Client</label>
|
|
||||||
<select class="form-select" v-model="form.client_id">
|
|
||||||
<option value="" disabled selected>Sélectionner un client</option>
|
|
||||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
|
||||||
{{ client.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<!-- Add client search/autocomplete if list is long -->
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #quote-details>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label>Date du devis</label>
|
|
||||||
<input type="date" class="form-control" v-model="form.quote_date">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label>Validité (Date)</label>
|
|
||||||
<input type="date" class="form-control" v-model="form.valid_until">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #product-lines>
|
|
||||||
<div v-for="(line, index) in form.lines" :key="index">
|
|
||||||
<product-line-item
|
|
||||||
v-model="form.lines[index]"
|
|
||||||
@remove="removeLine(index)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<soft-button
|
|
||||||
color="info"
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
@click="addLine"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
|
||||||
</soft-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #totals>
|
|
||||||
<ul class="list-group">
|
|
||||||
<li class="list-group-item d-flex justify-content-between">
|
|
||||||
<span>Total HT</span>
|
|
||||||
<strong>{{ formatCurrency(totals.ht) }}</strong>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item d-flex justify-content-between">
|
|
||||||
<span>TVA</span>
|
|
||||||
<strong>{{ formatCurrency(totals.tva) }}</strong>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item d-flex justify-content-between bg-gray-100">
|
|
||||||
<span>Total TTC</span>
|
|
||||||
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
</create-quote-template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import CreateQuoteTemplate from '@/components/templates/Quote/CreateQuoteTemplate.vue';
|
|
||||||
import ProductLineItem from '@/components/molecules/Quote/ProductLineItem.vue';
|
|
||||||
import SoftButton from '@/components/SoftButton.vue';
|
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
|
||||||
import { useClientStore } from '@/stores/clientStore';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const quoteStore = useQuoteStore();
|
|
||||||
const clientStore = useClientStore();
|
|
||||||
|
|
||||||
const { clients } = storeToRefs(clientStore);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
client_id: '',
|
|
||||||
quote_date: new Date().toISOString().split('T')[0],
|
|
||||||
valid_until: '',
|
|
||||||
status: 'brouillon',
|
|
||||||
currency: 'EUR',
|
|
||||||
lines: [
|
|
||||||
{ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = computed(() => {
|
|
||||||
let ht = 0;
|
|
||||||
let tva = 0;
|
|
||||||
|
|
||||||
form.value.lines.forEach(line => {
|
|
||||||
const lineHt = line.quantity * line.unit_price;
|
|
||||||
const lineTva = lineHt * (line.tva / 100);
|
|
||||||
ht += lineHt;
|
|
||||||
tva += lineTva;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ht,
|
|
||||||
tva,
|
|
||||||
ttc: ht + tva
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const addLine = () => {
|
|
||||||
form.value.lines.push({ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLine = (index) => {
|
|
||||||
form.value.lines.splice(index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveQuote = async () => {
|
|
||||||
if (!form.value.client_id) {
|
|
||||||
alert('Veuillez sélectionner un client');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
await quoteStore.createQuote({
|
|
||||||
client_id: form.value.client_id,
|
|
||||||
status: form.value.status,
|
|
||||||
quote_date: form.value.quote_date,
|
|
||||||
valid_until: form.value.valid_until,
|
|
||||||
currency: form.value.currency,
|
|
||||||
total_ht: totals.value.ht,
|
|
||||||
total_tva: totals.value.tva,
|
|
||||||
total_ttc: totals.value.ttc,
|
|
||||||
// Assuming backend handles lines separately or we need to pass them
|
|
||||||
// If backend expects lines in payload, we need to add them to interface and store
|
|
||||||
lines: form.value.lines.map(line => ({
|
|
||||||
...line,
|
|
||||||
discount_pct: line.discount_pct || 0,
|
|
||||||
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
|
|
||||||
total_ht: (line.quantity * line.unit_price) * (1 - (line.discount_pct || 0) / 100),
|
|
||||||
description: line.product_name || 'Produit sans nom' // Ensure description is set
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
router.push('/ventes/devis');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Erreur lors de la création du devis');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
clientStore.fetchClients();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
<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>
|
|
||||||
<quote-detail-template v-else-if="quote">
|
|
||||||
<template #header>
|
|
||||||
<div>
|
|
||||||
<h5 class="mb-0">Détails du Devis {{ quote.reference }}</h5>
|
|
||||||
<p class="text-sm mb-0">Créé le {{ formatDate(quote.created_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<soft-button color="secondary" variant="outline" size="sm" class="me-2" @click="goBack">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i> Retour
|
|
||||||
</soft-button>
|
|
||||||
<soft-button color="primary" variant="gradient" size="sm">
|
|
||||||
<i class="fas fa-edit me-1"></i> Modifier
|
|
||||||
</soft-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #info>
|
|
||||||
<quote-info-card
|
|
||||||
:reference="quote.reference"
|
|
||||||
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
|
|
||||||
:quote-date="quote.quote_date"
|
|
||||||
:valid-until="quote.valid_until"
|
|
||||||
:status="quote.status"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #lines>
|
|
||||||
<quote-lines-table :lines="quote.lines" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #totals>
|
|
||||||
<quote-totals-card
|
|
||||||
:totals="{
|
|
||||||
ht: quote.total_ht,
|
|
||||||
tva: quote.total_tva,
|
|
||||||
ttc: quote.total_ttc
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<soft-button v-if="quote.status === 'brouillon'" color="success" variant="gradient" class="me-2">
|
|
||||||
Valider le devis
|
|
||||||
</soft-button>
|
|
||||||
<soft-button color="info" variant="outline">
|
|
||||||
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
|
||||||
</soft-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</quote-detail-template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, defineProps } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
|
||||||
import QuoteDetailTemplate from '@/components/templates/Quote/QuoteDetailTemplate.vue';
|
|
||||||
import QuoteInfoCard from '@/components/molecules/Quote/QuoteInfoCard.vue';
|
|
||||||
import QuoteLinesTable from '@/components/molecules/Quote/QuoteLinesTable.vue';
|
|
||||||
import QuoteTotalsCard from '@/components/molecules/Quote/QuoteTotalsCard.vue';
|
|
||||||
import SoftButton from '@/components/SoftButton.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
quoteId: {
|
|
||||||
type: [String, Number],
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const quoteStore = useQuoteStore();
|
|
||||||
const quote = ref(null);
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref(null);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
|
|
||||||
quote.value = fetchedQuote;
|
|
||||||
} catch (e) {
|
|
||||||
error.value = "Impossible de charger le devis.";
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<template>
|
|
||||||
<list-quote-template>
|
|
||||||
<template #quote-new-action>
|
|
||||||
<add-button text="Ajouter" @click="openCreateModal"/>
|
|
||||||
</template>
|
|
||||||
<template #select-filter>
|
|
||||||
<filter-table />
|
|
||||||
</template>
|
|
||||||
<template #quote-table>
|
|
||||||
<quote-table
|
|
||||||
:data="quotes"
|
|
||||||
:loading="loading"
|
|
||||||
@view="handleView"
|
|
||||||
@edit="handleEdit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</list-quote-template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, computed } from 'vue';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { useRouter } from 'vue-router'; // Import router
|
|
||||||
import ListQuoteTemplate from '@/components/templates/Quote/ListQuoteTemplate.vue';
|
|
||||||
import addButton from '@/components/molecules/new-button/addButton.vue';
|
|
||||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
|
||||||
import QuoteTable from "@/components/molecules/Tables/Ventes/QuoteTable.vue";
|
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
|
||||||
|
|
||||||
const router = useRouter(); // Initialize router
|
|
||||||
const quoteStore = useQuoteStore();
|
|
||||||
const { quotes, loading } = storeToRefs(quoteStore);
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
router.push('/ventes/devis/new');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleView = (id) => {
|
|
||||||
router.push(`/ventes/devis/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (id) => {
|
|
||||||
console.log("Edit quote", id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce devis ?')) {
|
|
||||||
await quoteStore.deleteQuote(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
quoteStore.fetchQuotes();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="row align-items-center mb-3">
|
|
||||||
<!-- Product Search -->
|
|
||||||
<div class="col-5">
|
|
||||||
<div class="position-relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Rechercher un produit..."
|
|
||||||
:value="modelValue.product_name"
|
|
||||||
@input="onSearchInput"
|
|
||||||
/>
|
|
||||||
<div v-if="showResults && searchResults.length > 0" class="search-results shadow-sm border rounded">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li
|
|
||||||
v-for="product in searchResults"
|
|
||||||
:key="product.id"
|
|
||||||
class="list-group-item list-group-item-action cursor-pointer"
|
|
||||||
@click="selectProduct(product)"
|
|
||||||
>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<span class="fw-bold">{{ product.nom }}</span>
|
|
||||||
<br>
|
|
||||||
<small class="text-muted" v-if="product.reference">Ref: {{ product.reference }}</small>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-secondary">{{ product.stock_actuel }} en stock</span>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted d-block mt-1">{{ formatCurrency(product.prix_unitaire) }} HT</small>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quantity -->
|
|
||||||
<div class="col-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Qté"
|
|
||||||
min="1"
|
|
||||||
:value="modelValue.quantity"
|
|
||||||
@input="updateQuantity($event.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unit Price -->
|
|
||||||
<div class="col-2">
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Prix"
|
|
||||||
step="0.01"
|
|
||||||
:value="modelValue.unit_price"
|
|
||||||
@input="updatePrice($event.target.value)"
|
|
||||||
/>
|
|
||||||
<span class="input-group-text">€</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total Line -->
|
|
||||||
<div class="col-2 text-end">
|
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(lineTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Action -->
|
|
||||||
<div class="col-1 text-center">
|
|
||||||
<soft-button
|
|
||||||
color="danger"
|
|
||||||
variant="outline"
|
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm"
|
|
||||||
@click="$emit('remove')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</soft-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch, defineProps, defineEmits } from 'vue';
|
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
|
||||||
import { useProductStore } from '@/stores/productStore';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
default: () => ({
|
|
||||||
product_id: null,
|
|
||||||
product_name: '',
|
|
||||||
quantity: 1,
|
|
||||||
unit_price: 0,
|
|
||||||
tva: 20
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'remove']);
|
|
||||||
|
|
||||||
const productStore = useProductStore();
|
|
||||||
const searchResults = ref([]);
|
|
||||||
const showResults = ref(false);
|
|
||||||
let searchTimeout = null;
|
|
||||||
|
|
||||||
const lineTotal = computed(() => {
|
|
||||||
return props.modelValue.quantity * props.modelValue.unit_price;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSearchInput = (event) => {
|
|
||||||
const query = event.target.value;
|
|
||||||
// Clear product_id when user types, as it's no longer the selected product
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
product_name: query,
|
|
||||||
product_id: null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
|
||||||
|
|
||||||
if (query.length < 2) {
|
|
||||||
showResults.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const results = await productStore.searchProducts(query);
|
|
||||||
console.log('Search results:', results);
|
|
||||||
if (Array.isArray(results)) {
|
|
||||||
searchResults.value = results;
|
|
||||||
showResults.value = true;
|
|
||||||
} else if (results && results.data && Array.isArray(results.data)) {
|
|
||||||
// Fallback if the store returns the full response object by mistake
|
|
||||||
searchResults.value = results.data;
|
|
||||||
showResults.value = true;
|
|
||||||
} else {
|
|
||||||
searchResults.value = [];
|
|
||||||
showResults.value = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Search error:', e);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectProduct = (product) => {
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
product_id: product.id,
|
|
||||||
product_name: product.nom,
|
|
||||||
unit_price: product.prix_unitaire,
|
|
||||||
description: product.description || product.nom // Use description if available
|
|
||||||
});
|
|
||||||
showResults.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ... existing updateQuantity and updatePrice ...
|
|
||||||
const updateQuantity = (val) => {
|
|
||||||
updateField('quantity', parseFloat(val));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePrice = (val) => {
|
|
||||||
updateField('unit_price', parseFloat(val));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
|
||||||
emit('update:modelValue', {
|
|
||||||
...props.modelValue,
|
|
||||||
[field]: value
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Click outside handling
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
const searchContainer = event.target.closest('.position-relative');
|
|
||||||
if (!searchContainer && showResults.value) {
|
|
||||||
showResults.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
import { onMounted, onUnmounted } from 'vue';
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.search-results {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
background: white;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card shadow-none border h-100">
|
|
||||||
<div class="card-header pb-0 p-3">
|
|
||||||
<h6 class="mb-0">Informations</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
|
||||||
<span class="text-sm font-weight-bold text-dark">Référence:</span>
|
|
||||||
<span class="ms-2 text-sm">{{ reference }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
|
||||||
<span class="text-sm font-weight-bold text-dark">Client:</span>
|
|
||||||
<span class="ms-2 text-sm">{{ clientName }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
|
||||||
<span class="text-sm font-weight-bold text-dark">Date:</span>
|
|
||||||
<span class="ms-2 text-sm">{{ formatDate(quoteDate) }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
|
||||||
<span class="text-sm font-weight-bold text-dark">Validité:</span>
|
|
||||||
<span class="ms-2 text-sm">{{ formatDate(validUntil) }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
|
||||||
<span class="text-sm font-weight-bold text-dark">Statut:</span>
|
|
||||||
<span class="ms-2 badge badge-sm" :class="statusBadgeClass">{{ statusLabel }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, defineProps } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
reference: String,
|
|
||||||
clientName: String,
|
|
||||||
quoteDate: String,
|
|
||||||
validUntil: String,
|
|
||||||
status: String
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR');
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusBadgeClass = computed(() => {
|
|
||||||
switch (props.status) {
|
|
||||||
case 'brouillon': return 'bg-gradient-secondary';
|
|
||||||
case 'envoye': return 'bg-gradient-info';
|
|
||||||
case 'accepte': return 'bg-gradient-success';
|
|
||||||
case 'refuse': return 'bg-gradient-danger';
|
|
||||||
default: return 'bg-gradient-secondary';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
|
||||||
switch (props.status) {
|
|
||||||
case 'brouillon': return 'Brouillon';
|
|
||||||
case 'envoye': return 'Envoyé';
|
|
||||||
case 'accepte': return 'Accepté';
|
|
||||||
case 'refuse': return 'Refusé';
|
|
||||||
default: return props.status;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<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">Produit</th>
|
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Qté</th>
|
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Prix Unit.</th>
|
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Remise</th>
|
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2 text-end">Total HT</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="line in lines" :key="line.id">
|
|
||||||
<td>
|
|
||||||
<div class="d-flex flex-column justify-content-center">
|
|
||||||
<h6 class="mb-0 text-sm">{{ line.product_name || line.description }}</h6>
|
|
||||||
<p class="text-xs text-secondary mb-0" v-if="line.product && line.product.reference">{{ line.product.reference }}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ line.packages_qty || line.units_qty || line.quantity || line.qty_base }}</p>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ formatCurrency(line.unit_price) }}</p>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ line.discount_pct }}%</p>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ formatCurrency(line.total_ht) }}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="!lines || lines.length === 0">
|
|
||||||
<td colspan="5" class="text-center text-sm py-3">Aucune ligne de produit</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);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card shadow-none border bg-gray-100">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="text-sm text-secondary">Total HT</span>
|
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.ht) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="text-sm text-secondary">TVA</span>
|
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.tva) }}</span>
|
|
||||||
</div>
|
|
||||||
<hr class="horizontal dark my-2">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<span class="text-base text-dark font-weight-bold">Total TTC</span>
|
|
||||||
<span class="text-base text-primary font-weight-bold">{{ formatCurrency(totals.ttc) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { defineProps } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
totals: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
default: () => ({ ht: 0, tva: 0, ttc: 0 })
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@ -1,368 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="table-container">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loading" class="loading-container">
|
|
||||||
<div class="loading-spinner">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Chargement...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="loading-content">
|
|
||||||
<!-- Skeleton Rows -->
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-flush">
|
|
||||||
<thead class="thead-light">
|
|
||||||
<tr>
|
|
||||||
<th>Référence</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Validité</th>
|
|
||||||
<th>Total TTC</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
|
|
||||||
<!-- Reference Skeleton -->
|
|
||||||
<td><div class="skeleton-text medium"></div></td>
|
|
||||||
<!-- Client Skeleton -->
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="skeleton-avatar"></div>
|
|
||||||
<div class="skeleton-text medium ms-2"></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<!-- Date Skeleton -->
|
|
||||||
<td><div class="skeleton-text short"></div></td>
|
|
||||||
<!-- Validity Skeleton -->
|
|
||||||
<td><div class="skeleton-text short"></div></td>
|
|
||||||
<!-- Total Skeleton -->
|
|
||||||
<td><div class="skeleton-text short"></div></td>
|
|
||||||
<!-- Status Skeleton -->
|
|
||||||
<td><div class="skeleton-text short"></div></td>
|
|
||||||
<!-- Actions Skeleton -->
|
|
||||||
<td><div class="skeleton-icon small"></div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data State -->
|
|
||||||
<div v-else class="table-responsive">
|
|
||||||
<table id="quote-list" class="table table-flush">
|
|
||||||
<thead class="thead-light">
|
|
||||||
<tr>
|
|
||||||
<th>Référence</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Validité</th>
|
|
||||||
<th>Total TTC</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="quote in data" :key="quote.id">
|
|
||||||
<!-- Reference -->
|
|
||||||
<td class="text-sm font-weight-bold">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<soft-checkbox class="me-2" />
|
|
||||||
<span class="my-2 text-xs">{{ quote.reference }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Client -->
|
|
||||||
<td class="text-sm font-weight-bold">
|
|
||||||
<div class="d-flex px-2 py-1">
|
|
||||||
<div v-if="quote.client">
|
|
||||||
<soft-avatar
|
|
||||||
:img="getRandomAvatar()"
|
|
||||||
size="sm"
|
|
||||||
class="me-3"
|
|
||||||
alt="client image"
|
|
||||||
circular
|
|
||||||
/>
|
|
||||||
<div class="d-flex flex-column justify-content-center">
|
|
||||||
<h6 class="mb-0 text-sm">{{ quote.client.name }}</h6>
|
|
||||||
<p class="text-xs text-secondary mb-0">{{ quote.client.email }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-else class="text-xs text-secondary">Client Inconnu</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Date -->
|
|
||||||
<td class="text-xs font-weight-bold">
|
|
||||||
<span class="my-2 text-xs">{{ formatDate(quote.quote_date) }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Validity -->
|
|
||||||
<td class="text-xs font-weight-bold">
|
|
||||||
<span class="my-2 text-xs">{{ formatDate(quote.valid_until) }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Total TTC -->
|
|
||||||
<td class="text-xs font-weight-bold">
|
|
||||||
<span class="my-2 text-xs">{{ formatCurrency(quote.total_ttc) }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<td class="text-xs font-weight-bold">
|
|
||||||
<soft-badge :color="getStatusColor(quote.status)" variant="gradient" size="sm">
|
|
||||||
{{ getStatusLabel(quote.status) }}
|
|
||||||
</soft-badge>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="text-sm">
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<soft-button
|
|
||||||
color="info"
|
|
||||||
variant="outline"
|
|
||||||
title="Voir le devis"
|
|
||||||
:data-id="quote.id"
|
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
|
||||||
@click="$emit('view', quote.id)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
|
||||||
</soft-button>
|
|
||||||
<soft-button
|
|
||||||
color="primary"
|
|
||||||
variant="outline"
|
|
||||||
title="Modifier le devis"
|
|
||||||
:data-id="quote.id"
|
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
|
||||||
@click="$emit('edit', quote.id)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
|
|
||||||
</soft-button>
|
|
||||||
<soft-button
|
|
||||||
color="danger"
|
|
||||||
variant="outline"
|
|
||||||
title="Supprimer le devis"
|
|
||||||
:data-id="quote.id"
|
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
|
||||||
@click="$emit('delete', quote.id)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
|
||||||
</soft-button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="!loading && data.length === 0" class="empty-state">
|
|
||||||
<div class="empty-icon">
|
|
||||||
<i class="fas fa-file-invoice-dollar fa-3x text-muted"></i>
|
|
||||||
</div>
|
|
||||||
<h5 class="empty-title">Aucun devis trouvé</h5>
|
|
||||||
<p class="empty-text text-muted">
|
|
||||||
Aucun devis à afficher pour le moment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, watch, onUnmounted, defineProps, defineEmits } from "vue";
|
|
||||||
import { DataTable } from "simple-datatables";
|
|
||||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
|
||||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
|
||||||
import SoftBadge from "@/components/SoftBadge.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", "edit", "delete"]);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
data: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
skeletonRows: {
|
|
||||||
type: Number,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataTableInstance = ref(null);
|
|
||||||
|
|
||||||
const getRandomAvatar = () => {
|
|
||||||
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
|
||||||
return avatarImages[randomIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return "-";
|
|
||||||
const options = { year: 'numeric', month: 'long', day: '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 colors = {
|
|
||||||
brouillon: 'secondary',
|
|
||||||
envoye: 'info',
|
|
||||||
accepte: 'success',
|
|
||||||
refuse: 'danger',
|
|
||||||
expire: 'warning',
|
|
||||||
annule: 'danger'
|
|
||||||
};
|
|
||||||
return colors[status] || 'secondary';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status) => {
|
|
||||||
const labels = {
|
|
||||||
brouillon: 'Brouillon',
|
|
||||||
envoye: 'Envoyé',
|
|
||||||
accepte: 'Accepté',
|
|
||||||
refuse: 'Refusé',
|
|
||||||
expire: 'Expiré',
|
|
||||||
annule: 'Annulé'
|
|
||||||
};
|
|
||||||
return labels[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const initializeDataTable = () => {
|
|
||||||
if (dataTableInstance.value) {
|
|
||||||
dataTableInstance.value.destroy();
|
|
||||||
dataTableInstance.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataTableEl = document.getElementById("quote-list");
|
|
||||||
if (dataTableEl) {
|
|
||||||
dataTableInstance.value = new DataTable(dataTableEl, {
|
|
||||||
searchable: true,
|
|
||||||
fixedHeight: true,
|
|
||||||
perPage: 10,
|
|
||||||
labels: {
|
|
||||||
placeholder: "Rechercher...",
|
|
||||||
perPage: "{select} entrées par page",
|
|
||||||
noRows: "Aucune entrée trouvée",
|
|
||||||
info: "Affichage de {start} à {end} sur {rows} entrées",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.data,
|
|
||||||
() => {
|
|
||||||
if (!props.loading) {
|
|
||||||
setTimeout(() => {
|
|
||||||
initializeDataTable();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (dataTableInstance.value) {
|
|
||||||
dataTableInstance.value.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!props.loading && props.data.length > 0) {
|
|
||||||
initializeDataTable();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.table-container {
|
|
||||||
position: relative;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-content {
|
|
||||||
opacity: 0.7;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-row {
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-text {
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
border-radius: 4px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-text.short { width: 40px; }
|
|
||||||
.skeleton-text.medium { width: 80px; }
|
|
||||||
.skeleton-text.long { width: 120px; }
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header pb-0">
|
|
||||||
<div class="d-lg-flex">
|
|
||||||
<div>
|
|
||||||
<h5 class="mb-0">Nouveau Devis</h5>
|
|
||||||
<p class="text-sm mb-0">Créer un nouveau devis pour un client.</p>
|
|
||||||
</div>
|
|
||||||
<div class="ms-auto my-auto mt-lg-0 mt-4">
|
|
||||||
<div class="ms-auto my-auto">
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Client Selection -->
|
|
||||||
<div class="col-12 col-lg-4 mb-4">
|
|
||||||
<slot name="client-selection"></slot>
|
|
||||||
</div>
|
|
||||||
<!-- Quote Details -->
|
|
||||||
<div class="col-12 col-lg-8 mb-4">
|
|
||||||
<slot name="quote-details"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="horizontal dark my-4">
|
|
||||||
|
|
||||||
<!-- Product Lines -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="mb-3">Produits & Services</h6>
|
|
||||||
<slot name="product-lines"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="horizontal dark my-4">
|
|
||||||
|
|
||||||
<!-- Totals -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 col-lg-4 ms-auto">
|
|
||||||
<slot name="totals"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="d-sm-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<slot name="quote-new-action"></slot>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="dropdown d-inline">
|
|
||||||
<slot name="select-filter"></slot>
|
|
||||||
</div>
|
|
||||||
<slot name="quote-other-action"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card mt-4">
|
|
||||||
<slot name="quote-table"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script></script>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header pb-0 p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<slot name="header"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 col-md-4 mb-4">
|
|
||||||
<slot name="info"></slot>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-8">
|
|
||||||
<h6 class="text-uppercase text-body text-xs font-weight-bolder mb-3">Détail des lignes</h6>
|
|
||||||
<slot name="lines"></slot>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-6 ms-auto">
|
|
||||||
<slot name="totals"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer p-3">
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
@ -482,21 +482,11 @@ const routes = [
|
|||||||
name: "Devis",
|
name: "Devis",
|
||||||
component: () => import("@/views/pages/Ventes/Devis.vue"),
|
component: () => import("@/views/pages/Ventes/Devis.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/ventes/devis/:id",
|
|
||||||
name: "Quote Details",
|
|
||||||
component: () => import("@/views/pages/Ventes/QuoteDetail.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/ventes/factures",
|
path: "/ventes/factures",
|
||||||
name: "Factures ventes",
|
name: "Factures ventes",
|
||||||
component: () => import("@/views/pages/Ventes/Factures.vue"),
|
component: () => import("@/views/pages/Ventes/Factures.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/ventes/devis/new",
|
|
||||||
name: "Nouveau Devis",
|
|
||||||
component: () => import("@/views/pages/Ventes/NewQuote.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/ventes/statistiques",
|
path: "/ventes/statistiques",
|
||||||
name: "Statistiques ventes",
|
name: "Statistiques ventes",
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
import { request } from "./http";
|
|
||||||
import { Client } from "./client";
|
|
||||||
|
|
||||||
export interface Quote {
|
|
||||||
id: number;
|
|
||||||
client_id: number;
|
|
||||||
group_id: number | null;
|
|
||||||
reference: string;
|
|
||||||
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
|
|
||||||
quote_date: string;
|
|
||||||
valid_until: string | null;
|
|
||||||
currency: string;
|
|
||||||
total_ht: number;
|
|
||||||
total_tva: number;
|
|
||||||
total_ttc: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
client?: Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuoteListResponse {
|
|
||||||
data: Quote[];
|
|
||||||
meta?: {
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuoteResponse {
|
|
||||||
data: Quote;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuoteLine {
|
|
||||||
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; // Frontend helper, maps to units_qty usually or distinct
|
|
||||||
tva?: number; // Frontend helper
|
|
||||||
total_ht?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateQuotePayload {
|
|
||||||
client_id: number;
|
|
||||||
group_id?: number | null;
|
|
||||||
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
|
|
||||||
quote_date: string;
|
|
||||||
valid_until?: string | null;
|
|
||||||
currency: string;
|
|
||||||
total_ht: number;
|
|
||||||
total_tva: number;
|
|
||||||
total_ttc: number;
|
|
||||||
lines: QuoteLine[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateQuotePayload extends Partial<CreateQuotePayload> {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuoteService = {
|
|
||||||
/**
|
|
||||||
* Get all quotes with pagination
|
|
||||||
*/
|
|
||||||
async getAllQuotes(params?: {
|
|
||||||
page?: number;
|
|
||||||
per_page?: number;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
client_id?: number;
|
|
||||||
}): Promise<QuoteListResponse> {
|
|
||||||
const response = await request<QuoteListResponse>({
|
|
||||||
url: "/api/quotes",
|
|
||||||
method: "get",
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific quote by ID
|
|
||||||
*/
|
|
||||||
async getQuote(id: number): Promise<QuoteResponse> {
|
|
||||||
const response = await request<QuoteResponse>({
|
|
||||||
url: `/api/quotes/${id}`,
|
|
||||||
method: "get",
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new quote
|
|
||||||
*/
|
|
||||||
async createQuote(payload: CreateQuotePayload): Promise<QuoteResponse> {
|
|
||||||
const response = await request<QuoteResponse>({
|
|
||||||
url: "/api/quotes",
|
|
||||||
method: "post",
|
|
||||||
data: payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing quote
|
|
||||||
*/
|
|
||||||
async updateQuote(payload: UpdateQuotePayload): Promise<QuoteResponse> {
|
|
||||||
const { id, ...updateData } = payload;
|
|
||||||
|
|
||||||
const response = await request<QuoteResponse>({
|
|
||||||
url: `/api/quotes/${id}`,
|
|
||||||
method: "put",
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a quote
|
|
||||||
*/
|
|
||||||
async deleteQuote(
|
|
||||||
id: number
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
const response = await request<{ success: boolean; message: string }>({
|
|
||||||
url: `/api/quotes/${id}`,
|
|
||||||
method: "delete",
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QuoteService;
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
import { defineStore } from "pinia";
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import QuoteService, {
|
|
||||||
Quote,
|
|
||||||
CreateQuotePayload,
|
|
||||||
UpdateQuotePayload,
|
|
||||||
} from "@/services/quote";
|
|
||||||
|
|
||||||
export const useQuoteStore = defineStore("quote", () => {
|
|
||||||
// State
|
|
||||||
const quotes = ref<Quote[]>([]);
|
|
||||||
const currentQuote = ref<Quote | 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 allQuotes = computed(() => quotes.value);
|
|
||||||
const isLoading = computed(() => loading.value);
|
|
||||||
const hasError = computed(() => error.value !== null);
|
|
||||||
const getError = computed(() => error.value);
|
|
||||||
const getQuoteById = computed(() => (id: number) =>
|
|
||||||
quotes.value.find((quote) => quote.id === id)
|
|
||||||
);
|
|
||||||
const getPagination = computed(() => pagination.value);
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const setLoading = (isLoading: boolean) => {
|
|
||||||
loading.value = isLoading;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setError = (err: string | null) => {
|
|
||||||
error.value = err;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setQuotes = (newQuotes: Quote[]) => {
|
|
||||||
quotes.value = newQuotes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setCurrentQuote = (quote: Quote | null) => {
|
|
||||||
currentQuote.value = quote;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 quotes with optional pagination and filters
|
|
||||||
*/
|
|
||||||
const fetchQuotes = async (params?: {
|
|
||||||
page?: number;
|
|
||||||
per_page?: number;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
client_id?: number;
|
|
||||||
}) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await QuoteService.getAllQuotes(params);
|
|
||||||
setQuotes(response.data);
|
|
||||||
if (response.meta) {
|
|
||||||
setPagination(response.meta);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || err.message || "Failed to fetch quotes";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single quote by ID
|
|
||||||
*/
|
|
||||||
const fetchQuote = async (id: number) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await QuoteService.getQuote(id);
|
|
||||||
setCurrentQuote(response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || err.message || "Failed to fetch quote";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new quote
|
|
||||||
*/
|
|
||||||
const createQuote = async (payload: CreateQuotePayload) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await QuoteService.createQuote(payload);
|
|
||||||
// Add the new quote to the list
|
|
||||||
quotes.value.push(response.data);
|
|
||||||
setCurrentQuote(response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || err.message || "Failed to create quote";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing quote
|
|
||||||
*/
|
|
||||||
const updateQuote = async (payload: UpdateQuotePayload) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await QuoteService.updateQuote(payload);
|
|
||||||
const updatedQuote = response.data;
|
|
||||||
|
|
||||||
// Update in the quotes list
|
|
||||||
const index = quotes.value.findIndex(
|
|
||||||
(quote) => quote.id === updatedQuote.id
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
quotes.value[index] = updatedQuote;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current quote if it's the one being edited
|
|
||||||
if (currentQuote.value && currentQuote.value.id === updatedQuote.id) {
|
|
||||||
setCurrentQuote(updatedQuote);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedQuote;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || err.message || "Failed to update quote";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a quote
|
|
||||||
*/
|
|
||||||
const deleteQuote = async (id: number) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await QuoteService.deleteQuote(id);
|
|
||||||
|
|
||||||
// Remove from the quotes list
|
|
||||||
quotes.value = quotes.value.filter((quote) => quote.id !== id);
|
|
||||||
|
|
||||||
// Clear current quote if it's the one being deleted
|
|
||||||
if (currentQuote.value && currentQuote.value.id === id) {
|
|
||||||
setCurrentQuote(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || err.message || "Failed to delete quote";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
quotes,
|
|
||||||
currentQuote,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
pagination,
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
allQuotes,
|
|
||||||
isLoading,
|
|
||||||
hasError,
|
|
||||||
getError,
|
|
||||||
getQuoteById,
|
|
||||||
getPagination,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
fetchQuotes,
|
|
||||||
fetchQuote,
|
|
||||||
createQuote,
|
|
||||||
updateQuote,
|
|
||||||
deleteQuote,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<quote-list-presentation />
|
<div>
|
||||||
|
<h1>Devis</h1>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script>
|
||||||
import QuoteListPresentation from '@/components/Organism/Quote/QuoteListPresentation.vue';
|
export default {
|
||||||
|
name: "Devis",
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<quote-creation-presentation />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import QuoteCreationPresentation from '@/components/Organism/Quote/QuoteCreationPresentation.vue';
|
|
||||||
</script>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<template>
|
|
||||||
<quote-detail-presentation :quote-id="quoteId" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import QuoteDetailPresentation from '@/components/Organism/Quote/QuoteDetailPresentation.vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const quoteId = computed(() => route.params.id);
|
|
||||||
</script>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user