feat: Introduce client group management and enhance quote creation and detail views.
This commit is contained in:
parent
d911435b5c
commit
50f79a8040
@ -21,10 +21,12 @@ class UpdateQuoteRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$quoteId = $this->route('quote');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'client_id' => 'sometimes|exists:clients,id',
|
'client_id' => 'sometimes|exists:clients,id',
|
||||||
'group_id' => 'nullable|exists:client_groups,id',
|
'group_id' => 'nullable|exists:client_groups,id',
|
||||||
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $this->quote->id,
|
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $quoteId,
|
||||||
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
|
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
|
||||||
'quote_date' => 'sometimes|date',
|
'quote_date' => 'sometimes|date',
|
||||||
'valid_until' => 'nullable|date|after_or_equal:quote_date',
|
'valid_until' => 'nullable|date|after_or_equal:quote_date',
|
||||||
|
|||||||
25
thanasoft-back/app/Http/Resources/ClientGroupResource.php
Normal file
25
thanasoft-back/app/Http/Resources/ClientGroupResource.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ClientGroupResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class DocumentStatusHistoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'old_status' => $this->old_status,
|
||||||
|
'new_status' => $this->new_status,
|
||||||
|
'changed_at' => $this->changed_at,
|
||||||
|
'changed_by' => $this->user ? $this->user->name : 'System', // Simple user display
|
||||||
|
'comment' => $this->comment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,6 +30,8 @@ class QuoteResource extends JsonResource
|
|||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
'client' => $this->whenLoaded('client'),
|
'client' => $this->whenLoaded('client'),
|
||||||
'group' => $this->whenLoaded('group'),
|
'group' => $this->whenLoaded('group'),
|
||||||
|
'lines' => QuoteLineResource::collection($this->whenLoaded('lines')),
|
||||||
|
'history' => DocumentStatusHistoryResource::collection($this->whenLoaded('history')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
thanasoft-back/app/Models/DocumentStatusHistory.php
Normal file
44
thanasoft-back/app/Models/DocumentStatusHistory.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DocumentStatusHistory extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'document_status_history';
|
||||||
|
public $timestamps = false; // We are using changed_at
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'document_type',
|
||||||
|
'document_id',
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
'changed_by',
|
||||||
|
'changed_at',
|
||||||
|
'comment',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'changed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'changed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent document model (quote or invoice).
|
||||||
|
*/
|
||||||
|
public function document()
|
||||||
|
{
|
||||||
|
// define a custom polymorphic relationship or helper if needed
|
||||||
|
// Since it is an enum, we can't use standard morphTo easily without a map.
|
||||||
|
// But for now, I will just leave it or maybe add a helper.
|
||||||
|
// Standard Laravel morph expects 'document_type' to be the class name.
|
||||||
|
// Here it is 'quote' or 'invoice'.
|
||||||
|
// We can use morphMap in AppServiceProvider to map 'quote' => Quote::class.
|
||||||
|
return $this->morphTo(__FUNCTION__, 'document_type', 'document_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,4 +58,16 @@ class Quote extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(ClientGroup::class);
|
return $this->belongsTo(ClientGroup::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function lines()
|
||||||
|
{
|
||||||
|
return $this->hasMany(QuoteLine::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history()
|
||||||
|
{
|
||||||
|
return $this->hasMany(DocumentStatusHistory::class, 'document_id')
|
||||||
|
->where('document_type', 'quote')
|
||||||
|
->orderBy('changed_at', 'desc');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function create(array $data): Quote
|
public function create(array $data): Quote
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
return DB::transaction(function () use ($data) {
|
||||||
@ -32,17 +33,70 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record initial status history
|
||||||
|
$this->recordHistory($quote->id, null, $quote->status, 'Quote created');
|
||||||
|
|
||||||
return $quote;
|
return $quote;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Log the error
|
|
||||||
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
|
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Re-throw to trigger rollback
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(int|string $id, array $attributes): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($id, $attributes) {
|
||||||
|
try {
|
||||||
|
$quote = $this->find($id);
|
||||||
|
if (!$quote) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldStatus = $quote->status;
|
||||||
|
|
||||||
|
// Update the quote
|
||||||
|
$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, 'Quote status updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error updating quote: ' . $e->getMessage(), [
|
||||||
|
'id' => $id,
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int|string $id, array $columns = ['*']): ?Quote
|
||||||
|
{
|
||||||
|
return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordHistory(int $quoteId, ?string $oldStatus, string $newStatus, ?string $comment = null): void
|
||||||
|
{
|
||||||
|
\App\Models\DocumentStatusHistory::create([
|
||||||
|
'document_type' => 'quote',
|
||||||
|
'document_id' => $quoteId,
|
||||||
|
'old_status' => $oldStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
'changed_by' => auth()->id(), // Assuming authenticated user
|
||||||
|
'comment' => $comment,
|
||||||
|
'changed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?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('document_status_history', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->enum('document_type', ['quote', 'invoice']);
|
||||||
|
$table->unsignedBigInteger('document_id');
|
||||||
|
$table->string('old_status', 32)->nullable();
|
||||||
|
$table->string('new_status', 32);
|
||||||
|
$table->unsignedBigInteger('changed_by')->nullable();
|
||||||
|
$table->timestamp('changed_at')->useCurrent();
|
||||||
|
$table->text('comment')->nullable();
|
||||||
|
|
||||||
|
$table->index(['document_type', 'document_id'], 'idx_dsh_doc');
|
||||||
|
// Assuming we might want a foreign key for changed_by if users table exists, but user didn't explicitly ask for constraint, just column. I will leave it as column.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('document_status_history');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -438,10 +438,10 @@ const handleSubmit = async () => {
|
|||||||
if (interventionForm.value[key] != null) {
|
if (interventionForm.value[key] != null) {
|
||||||
let value = interventionForm.value[key];
|
let value = interventionForm.value[key];
|
||||||
// Fix date format for scheduled_at
|
// Fix date format for scheduled_at
|
||||||
if (key === 'scheduled_at' && value) {
|
if (key === "scheduled_at" && value) {
|
||||||
value = value.replace('T', ' ');
|
value = value.replace("T", " ");
|
||||||
if (value.length === 16) {
|
if (value.length === 16) {
|
||||||
value += ':00';
|
value += ":00";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
formData.append(`intervention[${key}]`, value);
|
formData.append(`intervention[${key}]`, value);
|
||||||
|
|||||||
@ -135,7 +135,10 @@
|
|||||||
placeholder="Code postal"
|
placeholder="Code postal"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
/>
|
/>
|
||||||
<div v-if="getFieldError('billing_postal_code')" class="invalid-feedback">
|
<div
|
||||||
|
v-if="getFieldError('billing_postal_code')"
|
||||||
|
class="invalid-feedback"
|
||||||
|
>
|
||||||
{{ getFieldError("billing_postal_code") }}
|
{{ getFieldError("billing_postal_code") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -176,8 +179,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn bg-gradient-primary"
|
class="btn bg-gradient-primary"
|
||||||
@click="$emit('next')"
|
|
||||||
:disabled="validating"
|
:disabled="validating"
|
||||||
|
@click="$emit('next')"
|
||||||
>
|
>
|
||||||
<span v-if="validating">
|
<span v-if="validating">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
||||||
|
|||||||
@ -97,8 +97,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn bg-gradient-primary"
|
class="btn bg-gradient-primary"
|
||||||
@click="$emit('next')"
|
|
||||||
:disabled="validating"
|
:disabled="validating"
|
||||||
|
@click="$emit('next')"
|
||||||
>
|
>
|
||||||
<span v-if="validating">
|
<span v-if="validating">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
||||||
|
|||||||
@ -9,16 +9,13 @@
|
|||||||
type="file"
|
type="file"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:class="{ 'is-invalid': hasError('death_certificate') }"
|
:class="{ 'is-invalid': hasError('death_certificate') }"
|
||||||
@change="handleFileUpload($event, 'death_certificate')"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
@change="handleFileUpload($event, 'death_certificate')"
|
||||||
/>
|
/>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Formats acceptés: PDF, JPG, PNG (Max 5MB)
|
Formats acceptés: PDF, JPG, PNG (Max 5MB)
|
||||||
</small>
|
</small>
|
||||||
<div
|
<div v-if="getFieldError('death_certificate')" class="invalid-feedback">
|
||||||
v-if="getFieldError('death_certificate')"
|
|
||||||
class="invalid-feedback"
|
|
||||||
>
|
|
||||||
{{ getFieldError("death_certificate") }}
|
{{ getFieldError("death_certificate") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,8 +25,8 @@
|
|||||||
type="file"
|
type="file"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:class="{ 'is-invalid': hasError('care_authorization') }"
|
:class="{ 'is-invalid': hasError('care_authorization') }"
|
||||||
@change="handleFileUpload($event, 'care_authorization')"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
@change="handleFileUpload($event, 'care_authorization')"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="getFieldError('care_authorization')"
|
v-if="getFieldError('care_authorization')"
|
||||||
@ -44,13 +41,10 @@
|
|||||||
type="file"
|
type="file"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:class="{ 'is-invalid': hasError('identity_document') }"
|
:class="{ 'is-invalid': hasError('identity_document') }"
|
||||||
@change="handleFileUpload($event, 'identity_document')"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
@change="handleFileUpload($event, 'identity_document')"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-if="getFieldError('identity_document')" class="invalid-feedback">
|
||||||
v-if="getFieldError('identity_document')"
|
|
||||||
class="invalid-feedback"
|
|
||||||
>
|
|
||||||
{{ getFieldError("identity_document") }}
|
{{ getFieldError("identity_document") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -60,14 +54,11 @@
|
|||||||
type="file"
|
type="file"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:class="{ 'is-invalid': hasError('other_documents') }"
|
:class="{ 'is-invalid': hasError('other_documents') }"
|
||||||
@change="handleFileUpload($event, 'other_documents')"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
multiple
|
multiple
|
||||||
|
@change="handleFileUpload($event, 'other_documents')"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-if="getFieldError('other_documents')" class="invalid-feedback">
|
||||||
v-if="getFieldError('other_documents')"
|
|
||||||
class="invalid-feedback"
|
|
||||||
>
|
|
||||||
{{ getFieldError("other_documents") }}
|
{{ getFieldError("other_documents") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -102,8 +93,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn bg-gradient-primary"
|
class="btn bg-gradient-primary"
|
||||||
@click="$emit('next')"
|
|
||||||
:disabled="validating"
|
:disabled="validating"
|
||||||
|
@click="$emit('next')"
|
||||||
>
|
>
|
||||||
<span v-if="validating">
|
<span v-if="validating">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
||||||
|
|||||||
@ -58,10 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Thanatopracteur</label>
|
<label class="form-label">Thanatopracteur</label>
|
||||||
<select
|
<select v-model="formData.assigned_practitioner_id" class="form-select">
|
||||||
v-model="formData.assigned_practitioner_id"
|
|
||||||
class="form-select"
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner un thanatopracteur</option>
|
<option value="">Sélectionner un thanatopracteur</option>
|
||||||
<option
|
<option
|
||||||
v-for="practitioner in practitioners"
|
v-for="practitioner in practitioners"
|
||||||
@ -158,8 +155,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["prev"]); // Submit is handled by form @submit in parent or here?
|
defineEmits(["prev"]); // Submit is handled by form @submit in parent or here?
|
||||||
// The parent form tag wraps all panels.
|
// The parent form tag wraps all panels.
|
||||||
// If this button is type="submit", it submits the parent form.
|
// If this button is type="submit", it submits the parent form.
|
||||||
// But wait, the parent sends 'handleSubmit' on form submit.
|
// But wait, the parent sends 'handleSubmit' on form submit.
|
||||||
// So this button just triggers the submit event of the form.
|
// So this button just triggers the submit event of the form.
|
||||||
|
|||||||
@ -99,8 +99,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn bg-gradient-primary"
|
class="btn bg-gradient-primary"
|
||||||
@click="$emit('next')"
|
|
||||||
:disabled="validating"
|
:disabled="validating"
|
||||||
|
@click="$emit('next')"
|
||||||
>
|
>
|
||||||
<span v-if="validating">
|
<span v-if="validating">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
||||||
|
|||||||
@ -7,29 +7,37 @@
|
|||||||
<label class="form-label">Type de produit *</label>
|
<label class="form-label">Type de produit *</label>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
<div v-if="loading" class="text-center py-3">
|
<div v-if="loading" class="text-center py-3">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Chargement...</span>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="interventionProducts.length === 0" class="text-center py-3 text-muted">
|
<div
|
||||||
Aucun produit d'intervention trouvé.
|
v-else-if="interventionProducts.length === 0"
|
||||||
|
class="text-center py-3 text-muted"
|
||||||
|
>
|
||||||
|
Aucun produit d'intervention trouvé.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
|
||||||
v-for="product in interventionProducts"
|
v-for="product in interventionProducts"
|
||||||
|
v-else
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
class="form-check p-3 border rounded"
|
class="form-check p-3 border rounded"
|
||||||
:class="{ 'border-primary bg-light': formData.product_id === product.id }"
|
:class="{
|
||||||
|
'border-primary bg-light': formData.product_id === product.id,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
:id="'product-' + product.id"
|
||||||
|
v-model="formData.product_id"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="productSelection"
|
name="productSelection"
|
||||||
:id="'product-' + product.id"
|
|
||||||
:value="product.id"
|
:value="product.id"
|
||||||
v-model="formData.product_id"
|
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label fw-bold w-100" :for="'product-' + product.id">
|
<label
|
||||||
|
class="form-check-label fw-bold w-100"
|
||||||
|
:for="'product-' + product.id"
|
||||||
|
>
|
||||||
{{ product.nom }}
|
{{ product.nom }}
|
||||||
<div class="text-muted fw-normal small">
|
<div class="text-muted fw-normal small">
|
||||||
{{ product.description || product.reference }}
|
{{ product.description || product.reference }}
|
||||||
@ -38,7 +46,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="getFieldError('product_type')" class="invalid-feedback d-block">
|
<div
|
||||||
|
v-if="getFieldError('product_type')"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
{{ getFieldError("product_type") }}
|
{{ getFieldError("product_type") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -52,8 +63,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn bg-gradient-primary"
|
class="btn bg-gradient-primary"
|
||||||
@click="$emit('next')"
|
|
||||||
:disabled="validating"
|
:disabled="validating"
|
||||||
|
@click="$emit('next')"
|
||||||
>
|
>
|
||||||
<span v-if="validating">
|
<span v-if="validating">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>
|
<i class="fas fa-spinner fa-spin me-2"></i>
|
||||||
@ -105,7 +116,10 @@ onMounted(async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// Fetch products that belong to intervention categories
|
// Fetch products that belong to intervention categories
|
||||||
const response = await productStore.fetchProducts({ is_intervention: true, per_page: 50 });
|
const response = await productStore.fetchProducts({
|
||||||
|
is_intervention: true,
|
||||||
|
per_page: 50,
|
||||||
|
});
|
||||||
interventionProducts.value = response.data;
|
interventionProducts.value = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching intervention products:", error);
|
console.error("Error fetching intervention products:", error);
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
<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>
|
||||||
|
<div v-else-if="clientGroup" class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h6>Détails du groupe</h6>
|
||||||
|
<div>
|
||||||
|
<soft-button
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
class="me-2"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</soft-button>
|
||||||
|
<soft-button color="info" variant="gradient" @click="handleEdit">
|
||||||
|
Modifier
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 mb-3">
|
||||||
|
<h6 class="text-sm text-uppercase text-muted">Nom</h6>
|
||||||
|
<p class="text-lg font-weight-bold">{{ clientGroup.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 mb-3">
|
||||||
|
<h6 class="text-sm text-uppercase text-muted">Description</h6>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{ clientGroup.description || "Aucune description" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h6 class="text-sm text-uppercase text-muted">Date de création</h6>
|
||||||
|
<p class="text-sm">{{ formatDate(clientGroup.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h6 class="text-sm text-uppercase text-muted">
|
||||||
|
Dernière modification
|
||||||
|
</h6>
|
||||||
|
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, defineProps } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useClientGroupStore } from "@/stores/clientGroupStore";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
groupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const clientGroupStore = useClientGroupStore();
|
||||||
|
const clientGroup = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const fetchedGroup = await clientGroupStore.fetchClientGroup(props.groupId);
|
||||||
|
clientGroup.value = fetchedGroup;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Impossible de charger le groupe.";
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push(`/clients/groups/${props.groupId}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
return new Date(dateString).toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<client-group-form
|
||||||
|
:initial-data="formData"
|
||||||
|
:is-edit="isEdit"
|
||||||
|
:loading="loading"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, defineProps } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useClientGroupStore } from "@/stores/clientGroupStore";
|
||||||
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
import ClientGroupForm from "@/components/molecules/ClientGroup/ClientGroupForm.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
groupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const clientGroupStore = useClientGroupStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
const formData = ref({ name: "", description: "" });
|
||||||
|
const loading = ref(false);
|
||||||
|
const isEdit = ref(!!props.groupId);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (props.groupId) {
|
||||||
|
try {
|
||||||
|
const group = await clientGroupStore.fetchClientGroup(props.groupId);
|
||||||
|
formData.value = {
|
||||||
|
name: group.name,
|
||||||
|
description: group.description || "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
"Impossible de charger le groupe",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
router.push("/clients/groups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (data) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await clientGroupStore.updateClientGroup({
|
||||||
|
id: props.groupId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
notificationStore.success(
|
||||||
|
"Groupe mis à jour",
|
||||||
|
"Le groupe a été mis à jour avec succès",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await clientGroupStore.createClientGroup(data);
|
||||||
|
notificationStore.success(
|
||||||
|
"Groupe créé",
|
||||||
|
"Le groupe a été créé avec succès",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
router.push("/clients/groups");
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
isEdit.value
|
||||||
|
? "Impossible de mettre à jour le groupe"
|
||||||
|
: "Impossible de créer le groupe",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push("/clients/groups");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<client-group-list-controls @create="openCreateModal" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<client-group-table
|
||||||
|
:data="clientGroups"
|
||||||
|
:loading="loading"
|
||||||
|
@view="handleView"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import ClientGroupListControls from "@/components/molecules/ClientGroup/ClientGroupListControls.vue";
|
||||||
|
import ClientGroupTable from "@/components/molecules/Tables/ClientGroup/ClientGroupTable.vue";
|
||||||
|
import { useClientGroupStore } from "@/stores/clientGroupStore";
|
||||||
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const clientGroupStore = useClientGroupStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
const { clientGroups, loading } = storeToRefs(clientGroupStore);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
router.push("/clients/groups/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (id) => {
|
||||||
|
console.log("handleView called with id:", id);
|
||||||
|
router.push(`/clients/groups/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id) => {
|
||||||
|
console.log("handleEdit called with id:", id);
|
||||||
|
router.push(`/clients/groups/${id}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (confirm("Êtes-vous sûr de vouloir supprimer ce groupe ?")) {
|
||||||
|
try {
|
||||||
|
await clientGroupStore.deleteClientGroup(id);
|
||||||
|
notificationStore.success(
|
||||||
|
"Groupe supprimé",
|
||||||
|
"Le groupe a été supprimé avec succès",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
"Impossible de supprimer le groupe",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
clientGroupStore.fetchClientGroups();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="alert alert-danger text-center py-4">
|
<div v-else-if="error" class="alert alert-danger text-center py-4">
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
<button @click="$emit('retry')" class="btn btn-outline-danger">
|
<button class="btn btn-outline-danger" @click="$emit('retry')">
|
||||||
Réessayer
|
Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,15 +12,15 @@
|
|||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="addPractitionerModalLabel">
|
<h5 id="addPractitionerModalLabel" class="modal-title">
|
||||||
<i class="fas fa-user-plus me-2"></i>
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
Ajouter un praticien
|
Ajouter un praticien
|
||||||
</h5>
|
</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="handleClose"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
@click="handleClose"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@ -54,8 +54,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from 'vue';
|
import { defineProps, defineEmits } from "vue";
|
||||||
import PractitionerSearchInput from '@/components/molecules/thanatopractitioner/PractitionerSearchInput.vue';
|
import PractitionerSearchInput from "@/components/molecules/thanatopractitioner/PractitionerSearchInput.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@ -72,18 +72,18 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'search', 'select']);
|
const emit = defineEmits(["close", "search", "select"]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
emit('close');
|
emit("close");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (query) => {
|
const handleSearch = (query) => {
|
||||||
emit('search', query);
|
emit("search", query);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (practitioner) => {
|
const handleSelect = (practitioner) => {
|
||||||
emit('select', practitioner);
|
emit("select", practitioner);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -165,8 +165,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-info ms-auto"
|
class="btn btn-sm btn-outline-info ms-auto"
|
||||||
@click="$emit('assign-practitioner')"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="$emit('assign-practitioner')"
|
||||||
>
|
>
|
||||||
Ajouter un thanatopracteur
|
Ajouter un thanatopracteur
|
||||||
</button>
|
</button>
|
||||||
@ -212,14 +212,14 @@
|
|||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-outline-danger"
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
title="Désassigner le praticien"
|
||||||
|
:disabled="loading"
|
||||||
@click="
|
@click="
|
||||||
$emit('unassign-practitioner', {
|
$emit('unassign-practitioner', {
|
||||||
practitionerId: practitioner.id,
|
practitionerId: practitioner.id,
|
||||||
practitionerName: practitioner.employee_name,
|
practitionerName: practitioner.employee_name,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
title="Désassigner le praticien"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
>
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
<span class="d-none d-sm-inline ms-1"
|
<span class="d-none d-sm-inline ms-1"
|
||||||
|
|||||||
@ -38,8 +38,8 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Nom du lieu *"
|
|
||||||
v-model="newLocation.name"
|
v-model="newLocation.name"
|
||||||
|
label="Nom du lieu *"
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
required
|
required
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
@ -47,8 +47,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Adresse"
|
|
||||||
v-model="newLocation.address"
|
v-model="newLocation.address"
|
||||||
|
label="Adresse"
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
@ -58,17 +58,17 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Téléphone"
|
|
||||||
v-model="newLocation.phone"
|
v-model="newLocation.phone"
|
||||||
|
label="Téléphone"
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
|
v-model="newLocation.email"
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
v-model="newLocation.email"
|
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
@ -77,10 +77,10 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
|
v-model="newLocation.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
v-model="newLocation.description"
|
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
placeholder="Informations supplémentaires sur le lieu..."
|
placeholder="Informations supplémentaires sur le lieu..."
|
||||||
/>
|
/>
|
||||||
@ -108,16 +108,16 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm bg-gradient-secondary"
|
class="btn btn-sm bg-gradient-secondary"
|
||||||
@click="closeCreateModal"
|
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
|
@click="closeCreateModal"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm bg-gradient-primary"
|
class="btn btn-sm bg-gradient-primary"
|
||||||
@click="submitLocation"
|
|
||||||
:disabled="creating || !newLocation.name.trim()"
|
:disabled="creating || !newLocation.name.trim()"
|
||||||
|
@click="submitLocation"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="creating"
|
v-if="creating"
|
||||||
|
|||||||
@ -47,7 +47,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ category.name }}</p>
|
<p class="text-xs font-weight-bold mb-0">{{ category.name }}</p>
|
||||||
<p class="text-xs text-secondary mb-0" v-if="category.description">
|
<p
|
||||||
|
v-if="category.description"
|
||||||
|
class="text-xs text-secondary mb-0"
|
||||||
|
>
|
||||||
{{ category.description }}
|
{{ category.description }}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@ -59,15 +62,23 @@
|
|||||||
<td class="align-middle text-center text-sm">
|
<td class="align-middle text-center text-sm">
|
||||||
<span
|
<span
|
||||||
class="badge badge-sm"
|
class="badge badge-sm"
|
||||||
:class="category.active ? 'bg-gradient-success' : 'bg-gradient-secondary'"
|
:class="
|
||||||
|
category.active
|
||||||
|
? 'bg-gradient-success'
|
||||||
|
: 'bg-gradient-secondary'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ category.active ? "Actif" : "Inactif" }}
|
{{ category.active ? "Actif" : "Inactif" }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle text-center text-sm">
|
<td class="align-middle text-center text-sm">
|
||||||
<span
|
<span
|
||||||
class="badge badge-sm"
|
class="badge badge-sm"
|
||||||
:class="category.intervention ? 'bg-gradient-info' : 'bg-gradient-light text-dark'"
|
:class="
|
||||||
|
category.intervention
|
||||||
|
? 'bg-gradient-info'
|
||||||
|
: 'bg-gradient-light text-dark'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ category.intervention ? "Oui" : "Non" }}
|
{{ category.intervention ? "Oui" : "Non" }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
|
||||||
id="productCategoryModal"
|
id="productCategoryModal"
|
||||||
|
ref="modalRef"
|
||||||
|
class="modal fade"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="productCategoryModalLabel"
|
aria-labelledby="productCategoryModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref="modalRef"
|
|
||||||
>
|
>
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="productCategoryModalLabel">
|
<h5 id="productCategoryModalLabel" class="modal-title">
|
||||||
{{ isEditing ? "Modifier la catégorie" : "Nouvelle catégorie" }}
|
{{ isEditing ? "Modifier la catégorie" : "Nouvelle catégorie" }}
|
||||||
</h5>
|
</h5>
|
||||||
<button
|
<button
|
||||||
@ -28,10 +28,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="categoryCode" class="form-label">Code</label>
|
<label for="categoryCode" class="form-label">Code</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="categoryCode"
|
id="categoryCode"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
required
|
required
|
||||||
:disabled="isEditing"
|
:disabled="isEditing"
|
||||||
placeholder="Ex: SOINS, URNE"
|
placeholder="Ex: SOINS, URNE"
|
||||||
@ -40,10 +40,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="categoryName" class="form-label">Nom</label>
|
<label for="categoryName" class="form-label">Nom</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="categoryName"
|
id="categoryName"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
required
|
required
|
||||||
placeholder="Ex: Soins de conservation"
|
placeholder="Ex: Soins de conservation"
|
||||||
/>
|
/>
|
||||||
@ -53,9 +53,9 @@
|
|||||||
>Catégorie Parente</label
|
>Catégorie Parente</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
class="form-select"
|
|
||||||
id="parentCategory"
|
id="parentCategory"
|
||||||
v-model="form.parent_id"
|
v-model="form.parent_id"
|
||||||
|
class="form-select"
|
||||||
>
|
>
|
||||||
<option :value="null">Aucune (Racine)</option>
|
<option :value="null">Aucune (Racine)</option>
|
||||||
<option
|
<option
|
||||||
@ -72,18 +72,18 @@
|
|||||||
>Description</label
|
>Description</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
class="form-control"
|
|
||||||
id="categoryDescription"
|
id="categoryDescription"
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
|
class="form-control"
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input
|
<input
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id="interventionSwitch"
|
id="interventionSwitch"
|
||||||
v-model="form.intervention"
|
v-model="form.intervention"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="interventionSwitch"
|
<label class="form-check-label" for="interventionSwitch"
|
||||||
>Lié à une intervention ?</label
|
>Lié à une intervention ?</label
|
||||||
@ -91,10 +91,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input
|
<input
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id="activeSwitch"
|
id="activeSwitch"
|
||||||
v-model="form.active"
|
v-model="form.active"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="activeSwitch">Actif</label>
|
<label class="form-check-label" for="activeSwitch">Actif</label>
|
||||||
</div>
|
</div>
|
||||||
@ -111,8 +111,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="handleSubmit"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
loading
|
loading
|
||||||
@ -129,7 +129,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, defineProps, defineEmits, watch, defineExpose } from "vue";
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
watch,
|
||||||
|
defineExpose,
|
||||||
|
} from "vue";
|
||||||
import { Modal } from "bootstrap";
|
import { Modal } from "bootstrap";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -167,8 +175,8 @@ const isEditing = computed(() => !!props.category);
|
|||||||
// Filter out self and children from parent selection to avoid loops
|
// Filter out self and children from parent selection to avoid loops
|
||||||
const availableParents = computed(() => {
|
const availableParents = computed(() => {
|
||||||
if (!isEditing.value || !form.value.id) return props.allCategories;
|
if (!isEditing.value || !form.value.id) return props.allCategories;
|
||||||
|
|
||||||
return props.allCategories.filter(c => c.id !== form.value.id); // Simple check, ideally check recursive children
|
return props.allCategories.filter((c) => c.id !== form.value.id); // Simple check, ideally check recursive children
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
|||||||
@ -137,9 +137,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="handleViewSupplier(productData.fournisseur)"
|
|
||||||
class="supplier-action-btn"
|
class="supplier-action-btn"
|
||||||
title="Voir le fournisseur"
|
title="Voir le fournisseur"
|
||||||
|
@click="handleViewSupplier(productData.fournisseur)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,93 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<create-quote-template>
|
<create-quote-template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<soft-button
|
<soft-button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<soft-button
|
<soft-button
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
@click="saveQuote"
|
:disabled="loading"
|
||||||
:disabled="loading"
|
@click="saveQuote"
|
||||||
>
|
>
|
||||||
{{ loading ? 'Enregistrement...' : 'Enregistrer' }}
|
{{ loading ? "Enregistrement..." : "Enregistrer" }}
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #client-selection>
|
<template #client-selection>
|
||||||
<label>Client</label>
|
<label>Client</label>
|
||||||
<select class="form-select" v-model="form.client_id">
|
<select v-model="form.client_id" class="form-select">
|
||||||
<option value="" disabled selected>Sélectionner un client</option>
|
<option value="" disabled selected>Sélectionner un client</option>
|
||||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
{{ client.name }}
|
{{ client.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<!-- Add client search/autocomplete if list is long -->
|
<!-- Add client search/autocomplete if list is long -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #quote-details>
|
<template #quote-details>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label>Date du devis</label>
|
<label>Date du devis</label>
|
||||||
<input type="date" class="form-control" v-model="form.quote_date">
|
<input v-model="form.quote_date" type="date" class="form-control" />
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label>Validité (Date)</label>
|
|
||||||
<input type="date" class="form-control" v-model="form.valid_until">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label>Validité (Date)</label>
|
||||||
|
<input v-model="form.valid_until" type="date" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #product-lines>
|
<template #product-lines>
|
||||||
<div v-for="(line, index) in form.lines" :key="index">
|
<div v-for="(line, index) in form.lines" :key="index">
|
||||||
<product-line-item
|
<product-line-item
|
||||||
v-model="form.lines[index]"
|
v-model="form.lines[index]"
|
||||||
@remove="removeLine(index)"
|
@remove="removeLine(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<soft-button
|
<soft-button color="info" variant="text" size="sm" @click="addLine">
|
||||||
color="info"
|
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
||||||
variant="text"
|
</soft-button>
|
||||||
size="sm"
|
|
||||||
@click="addLine"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus me-1"></i> Ajouter une ligne
|
|
||||||
</soft-button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #totals>
|
<template #totals>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
<li class="list-group-item d-flex justify-content-between">
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
<span>Total HT</span>
|
<span>Total HT</span>
|
||||||
<strong>{{ formatCurrency(totals.ht) }}</strong>
|
<strong>{{ formatCurrency(totals.ht) }}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between">
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
<span>TVA</span>
|
<span>TVA</span>
|
||||||
<strong>{{ formatCurrency(totals.tva) }}</strong>
|
<strong>{{ formatCurrency(totals.tva) }}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between bg-gray-100">
|
<li class="list-group-item d-flex justify-content-between bg-gray-100">
|
||||||
<span>Total TTC</span>
|
<span>Total TTC</span>
|
||||||
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
|
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
</create-quote-template>
|
</create-quote-template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from "vue-router";
|
||||||
import CreateQuoteTemplate from '@/components/templates/Quote/CreateQuoteTemplate.vue';
|
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
|
||||||
import ProductLineItem from '@/components/molecules/Quote/ProductLineItem.vue';
|
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
|
||||||
import SoftButton from '@/components/SoftButton.vue';
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
import { useQuoteStore } from "@/stores/quoteStore";
|
||||||
import { useClientStore } from '@/stores/clientStore';
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const quoteStore = useQuoteStore();
|
const quoteStore = useQuoteStore();
|
||||||
@ -97,87 +92,107 @@ const { clients } = storeToRefs(clientStore);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
client_id: '',
|
client_id: "",
|
||||||
quote_date: new Date().toISOString().split('T')[0],
|
quote_date: new Date().toISOString().split("T")[0],
|
||||||
valid_until: '',
|
valid_until: "",
|
||||||
status: 'brouillon',
|
status: "brouillon",
|
||||||
currency: 'EUR',
|
currency: "EUR",
|
||||||
lines: [
|
lines: [
|
||||||
{ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 }
|
{
|
||||||
]
|
product_id: null,
|
||||||
|
product_name: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
tva: 20,
|
||||||
|
discount_pct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
let ht = 0;
|
let ht = 0;
|
||||||
let tva = 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 {
|
form.value.lines.forEach((line) => {
|
||||||
ht,
|
const lineHt = line.quantity * line.unit_price;
|
||||||
tva,
|
const lineTva = lineHt * (line.tva / 100);
|
||||||
ttc: ht + tva
|
ht += lineHt;
|
||||||
};
|
tva += lineTva;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ht,
|
||||||
|
tva,
|
||||||
|
ttc: ht + tva,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const addLine = () => {
|
const addLine = () => {
|
||||||
form.value.lines.push({ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 });
|
form.value.lines.push({
|
||||||
|
product_id: null,
|
||||||
|
product_name: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
tva: 20,
|
||||||
|
discount_pct: 0,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLine = (index) => {
|
const removeLine = (index) => {
|
||||||
form.value.lines.splice(index, 1);
|
form.value.lines.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveQuote = async () => {
|
const saveQuote = async () => {
|
||||||
if (!form.value.client_id) {
|
if (!form.value.client_id) {
|
||||||
alert('Veuillez sélectionner un client');
|
alert("Veuillez sélectionner un client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await quoteStore.createQuote({
|
await quoteStore.createQuote({
|
||||||
client_id: form.value.client_id,
|
client_id: form.value.client_id,
|
||||||
status: form.value.status,
|
status: form.value.status,
|
||||||
quote_date: form.value.quote_date,
|
quote_date: form.value.quote_date,
|
||||||
valid_until: form.value.valid_until,
|
valid_until: form.value.valid_until,
|
||||||
currency: form.value.currency,
|
currency: form.value.currency,
|
||||||
total_ht: totals.value.ht,
|
total_ht: totals.value.ht,
|
||||||
total_tva: totals.value.tva,
|
total_tva: totals.value.tva,
|
||||||
total_ttc: totals.value.ttc,
|
total_ttc: totals.value.ttc,
|
||||||
// Assuming backend handles lines separately or we need to pass them
|
// 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
|
// If backend expects lines in payload, we need to add them to interface and store
|
||||||
lines: form.value.lines.map(line => ({
|
lines: form.value.lines.map((line) => ({
|
||||||
...line,
|
...line,
|
||||||
discount_pct: line.discount_pct || 0,
|
discount_pct: line.discount_pct || 0,
|
||||||
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
|
// 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),
|
total_ht:
|
||||||
description: line.product_name || 'Produit sans nom' // Ensure description is set
|
line.quantity *
|
||||||
}))
|
line.unit_price *
|
||||||
});
|
(1 - (line.discount_pct || 0) / 100),
|
||||||
router.push('/ventes/devis');
|
description: line.product_name || "Produit sans nom", // Ensure description is set
|
||||||
} catch (error) {
|
})),
|
||||||
console.error(error);
|
});
|
||||||
alert('Erreur lors de la création du devis');
|
router.push("/ventes/devis");
|
||||||
} finally {
|
} catch (error) {
|
||||||
loading.value = false;
|
console.error(error);
|
||||||
}
|
alert("Erreur lors de la création du devis");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
clientStore.fetchClients();
|
clientStore.fetchClients();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,107 +1,183 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="text-center py-5">
|
<div v-if="loading" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="text-center py-5 text-danger">
|
<div v-else-if="error" class="text-center py-5 text-danger">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<quote-detail-template v-else-if="quote">
|
<quote-detail-template v-else-if="quote">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div>
|
<quote-header
|
||||||
<h5 class="mb-0">Détails du Devis {{ quote.reference }}</h5>
|
:reference="quote.reference"
|
||||||
<p class="text-sm mb-0">Créé le {{ formatDate(quote.created_at) }}</p>
|
:date="quote.quote_date"
|
||||||
</div>
|
:code="quote.reference"
|
||||||
<div>
|
/>
|
||||||
<soft-button color="secondary" variant="outline" size="sm" class="me-2" @click="goBack">
|
</template>
|
||||||
<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>
|
<template #lines>
|
||||||
<quote-info-card
|
<quote-lines-table :lines="quote.lines" />
|
||||||
:reference="quote.reference"
|
</template>
|
||||||
: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>
|
<template #timeline>
|
||||||
<quote-lines-table :lines="quote.lines" />
|
<quote-timeline :history="quote.history" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #totals>
|
<template #billing>
|
||||||
<quote-totals-card
|
<quote-billing-info
|
||||||
:totals="{
|
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
|
||||||
ht: quote.total_ht,
|
:client-email="quote.client ? quote.client.email : ''"
|
||||||
tva: quote.total_tva,
|
:client-phone="quote.client ? quote.client.phone : ''"
|
||||||
ttc: quote.total_ttc
|
/>
|
||||||
}"
|
</template>
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
<template #summary>
|
||||||
<div class="d-flex justify-content-end">
|
<quote-summary
|
||||||
<soft-button v-if="quote.status === 'brouillon'" color="success" variant="gradient" class="me-2">
|
:ht="quote.total_ht"
|
||||||
Valider le devis
|
:tva="quote.total_tva"
|
||||||
</soft-button>
|
:ttc="quote.total_ttc"
|
||||||
<soft-button color="info" variant="outline">
|
/>
|
||||||
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
</template>
|
||||||
</soft-button>
|
|
||||||
</div>
|
<template #actions>
|
||||||
</template>
|
<div class="d-flex justify-content-end">
|
||||||
|
<div class="dropdown d-inline-block me-2">
|
||||||
|
<soft-button
|
||||||
|
id="dropdownMenuButton"
|
||||||
|
color="secondary"
|
||||||
|
variant="gradient"
|
||||||
|
class="dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(quote.status) }}
|
||||||
|
</soft-button>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||||
|
<li v-for="status in availableStatuses" :key="status">
|
||||||
|
<a
|
||||||
|
class="dropdown-item"
|
||||||
|
:class="{ active: status === quote.status }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="changeStatus(status)"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(status) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<soft-button color="info" variant="outline">
|
||||||
|
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</quote-detail-template>
|
</quote-detail-template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, defineProps } from 'vue';
|
import { ref, onMounted, defineProps } from "vue";
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from "vue-router";
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
import { useQuoteStore } from "@/stores/quoteStore";
|
||||||
import QuoteDetailTemplate from '@/components/templates/Quote/QuoteDetailTemplate.vue';
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
import QuoteInfoCard from '@/components/molecules/Quote/QuoteInfoCard.vue';
|
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
|
||||||
import QuoteLinesTable from '@/components/molecules/Quote/QuoteLinesTable.vue';
|
import QuoteHeader from "@/components/molecules/Quote/QuoteHeader.vue";
|
||||||
import QuoteTotalsCard from '@/components/molecules/Quote/QuoteTotalsCard.vue';
|
import QuoteTimeline from "@/components/molecules/Quote/QuoteTimeline.vue";
|
||||||
import SoftButton from '@/components/SoftButton.vue';
|
import QuoteBillingInfo from "@/components/molecules/Quote/QuoteBillingInfo.vue";
|
||||||
|
import QuoteSummary from "@/components/molecules/Quote/QuoteSummary.vue";
|
||||||
|
import QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quoteId: {
|
quoteId: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
required: true
|
required: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const quoteStore = useQuoteStore();
|
const quoteStore = useQuoteStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
const quote = ref(null);
|
const quote = ref(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
|
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
|
||||||
quote.value = fetchedQuote;
|
quote.value = fetchedQuote;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = "Impossible de charger le devis.";
|
error.value = "Impossible de charger le devis.";
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return "-";
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR');
|
return new Date(dateString).toLocaleDateString("fr-FR");
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
const availableStatuses = [
|
||||||
|
"brouillon",
|
||||||
|
"envoye",
|
||||||
|
"accepte",
|
||||||
|
"refuse",
|
||||||
|
"expire",
|
||||||
|
"annule",
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
brouillon: "Brouillon",
|
||||||
|
envoye: "Envoyé",
|
||||||
|
accepte: "Accepté",
|
||||||
|
refuse: "Refusé",
|
||||||
|
expire: "Expiré",
|
||||||
|
annule: "Annulé",
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* eslint-disable require-atomic-updates */
|
||||||
|
const changeStatus = async (newStatus) => {
|
||||||
|
if (!quote.value?.id) return;
|
||||||
|
|
||||||
|
// Capture the current quote ID to prevent race conditions
|
||||||
|
const currentQuoteId = quote.value.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const updated = await quoteStore.updateQuote({
|
||||||
|
id: currentQuoteId,
|
||||||
|
status: newStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if we're still viewing the same quote
|
||||||
|
if (quote.value?.id === currentQuoteId) {
|
||||||
|
quote.value = updated;
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
notificationStore.success(
|
||||||
|
'Statut mis à jour',
|
||||||
|
`Le devis est maintenant "${getStatusLabel(newStatus)}"`,
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update status", e);
|
||||||
|
notificationStore.error(
|
||||||
|
'Erreur',
|
||||||
|
'Impossible de mettre à jour le statut',
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -1,56 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<list-quote-template>
|
<div class="container-fluid py-4">
|
||||||
<template #quote-new-action>
|
<quote-list-controls @create="openCreateModal" />
|
||||||
<add-button text="Ajouter" @click="openCreateModal"/>
|
<div class="row">
|
||||||
</template>
|
<div class="col-12">
|
||||||
<template #select-filter>
|
<quote-table
|
||||||
<filter-table />
|
:data="quotes"
|
||||||
</template>
|
:loading="loading"
|
||||||
<template #quote-table>
|
@view="handleView"
|
||||||
<quote-table
|
@delete="handleDelete"
|
||||||
:data="quotes"
|
/>
|
||||||
:loading="loading"
|
</div>
|
||||||
@view="handleView"
|
</div>
|
||||||
@edit="handleEdit"
|
</div>
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</list-quote-template>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed } from 'vue';
|
import { onMounted } from "vue";
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from "pinia";
|
||||||
import { useRouter } from 'vue-router'; // Import router
|
import { useRouter } from "vue-router";
|
||||||
import ListQuoteTemplate from '@/components/templates/Quote/ListQuoteTemplate.vue';
|
import QuoteListControls from "@/components/molecules/Quote/QuoteListControls.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 QuoteTable from "@/components/molecules/Tables/Ventes/QuoteTable.vue";
|
||||||
import { useQuoteStore } from '@/stores/quoteStore';
|
import { useQuoteStore } from "@/stores/quoteStore";
|
||||||
|
|
||||||
const router = useRouter(); // Initialize router
|
const router = useRouter();
|
||||||
const quoteStore = useQuoteStore();
|
const quoteStore = useQuoteStore();
|
||||||
const { quotes, loading } = storeToRefs(quoteStore);
|
const { quotes, loading } = storeToRefs(quoteStore);
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
router.push('/ventes/devis/new');
|
router.push("/ventes/devis/new");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleView = (id) => {
|
const handleView = (id) => {
|
||||||
router.push(`/ventes/devis/${id}`);
|
console.log("handleView called with id:", id);
|
||||||
|
router.push(`/ventes/devis/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (id) => {
|
const handleEdit = (id) => {
|
||||||
console.log("Edit quote", id);
|
console.log("Edit quote", id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce devis ?')) {
|
if (confirm("Êtes-vous sûr de vouloir supprimer ce devis ?")) {
|
||||||
await quoteStore.deleteQuote(id);
|
await quoteStore.deleteQuote(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
quoteStore.fetchQuotes();
|
quoteStore.fetchQuotes();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="reference" class="form-label"
|
<label for="reference" class="form-label"
|
||||||
>Référence *</label
|
>Référence *</label
|
||||||
>
|
>
|
||||||
@ -102,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="fabricant" class="form-label">Fabricant</label>
|
<label for="fabricant" class="form-label">Fabricant</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
id="fabricant"
|
id="fabricant"
|
||||||
@ -124,7 +124,7 @@
|
|||||||
<!-- Stock Information -->
|
<!-- Stock Information -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="stock_actuel" class="form-label"
|
<label for="stock_actuel" class="form-label"
|
||||||
>Stock Actuel *</label
|
>Stock Actuel *</label
|
||||||
>
|
>
|
||||||
@ -148,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="stock_minimum" class="form-label"
|
<label for="stock_minimum" class="form-label"
|
||||||
>Stock Minimum *</label
|
>Stock Minimum *</label
|
||||||
>
|
>
|
||||||
@ -171,7 +171,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="col-md-4">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="unite" class="form-label">Unité *</label>
|
<label for="unite" class="form-label">Unité *</label>
|
||||||
<select
|
<select
|
||||||
@ -232,7 +232,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="date_expiration" class="form-label"
|
<label for="date_expiration" class="form-label"
|
||||||
>Date d'Expiration</label
|
>Date d'Expiration</label
|
||||||
>
|
>
|
||||||
@ -258,7 +258,7 @@
|
|||||||
<!-- Lot Number -->
|
<!-- Lot Number -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="numero_lot" class="form-label"
|
<label for="numero_lot" class="form-label"
|
||||||
>Numéro de Lot</label
|
>Numéro de Lot</label
|
||||||
>
|
>
|
||||||
@ -279,7 +279,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="form-group">
|
||||||
<label for="fournisseur_id" class="form-label"
|
<label for="fournisseur_id" class="form-label"
|
||||||
>Fournisseur</label
|
>Fournisseur</label
|
||||||
>
|
>
|
||||||
@ -309,7 +309,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Packaging Information -->
|
<!-- Packaging Information -->
|
||||||
<div class="row" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="conditionnement_nom" class="form-label"
|
<label for="conditionnement_nom" class="form-label"
|
||||||
@ -383,7 +383,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URLs -->
|
<!-- URLs -->
|
||||||
<div class="row" v-if="!isIntervention">
|
<div v-if="!isIntervention" class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="photo_url" class="form-label"
|
<label for="photo_url" class="form-label"
|
||||||
|
|||||||
@ -28,8 +28,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-2 mb-0 btn bg-gradient-danger"
|
class="mt-2 mb-0 btn bg-gradient-danger"
|
||||||
@click="deleteProduct"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="deleteProduct"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash me-2"></i>
|
<i class="fas fa-trash me-2"></i>
|
||||||
Supprimer
|
Supprimer
|
||||||
@ -46,8 +46,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-2 mb-0 btn bg-gradient-success"
|
class="mt-2 mb-0 btn bg-gradient-success"
|
||||||
@click="saveProduct"
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
|
@click="saveProduct"
|
||||||
>
|
>
|
||||||
<i v-if="saving" class="fas fa-spinner fa-spin me-2"></i>
|
<i v-if="saving" class="fas fa-spinner fa-spin me-2"></i>
|
||||||
<i v-else class="fas fa-save me-2"></i>
|
<i v-else class="fas fa-save me-2"></i>
|
||||||
@ -466,9 +466,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="handleViewSupplier(productData.fournisseur)"
|
|
||||||
class="supplier-action-btn"
|
class="supplier-action-btn"
|
||||||
title="Voir le fournisseur"
|
title="Voir le fournisseur"
|
||||||
|
@click="handleViewSupplier(productData.fournisseur)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="btn bg-gradient-primary mb-0"
|
class="btn bg-gradient-primary mb-0"
|
||||||
@click="$emit('click')"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="$emit('click')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-plus me-2"></i>
|
<i class="fas fa-plus me-2"></i>
|
||||||
{{ text }}
|
{{ text }}
|
||||||
|
|||||||
@ -19,16 +19,16 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-secondary"
|
class="btn btn-sm btn-outline-secondary"
|
||||||
@click="$emit('edit', location)"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
@click="$emit('edit', location)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-danger ms-1"
|
class="btn btn-sm btn-outline-danger ms-1"
|
||||||
@click="$emit('remove')"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
@click="$emit('remove')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="location-search-input">
|
<div class="location-search-input">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
|
v-model="searchQuery"
|
||||||
:label="label"
|
:label="label"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
v-model="searchQuery"
|
|
||||||
@input="handleSearch"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="{ 'is-invalid': hasError }"
|
:class="{ 'is-invalid': hasError }"
|
||||||
|
@input="handleSearch"
|
||||||
|
@keydown="handleKeydown"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Search Results Dropdown -->
|
<!-- Search Results Dropdown -->
|
||||||
@ -20,9 +20,9 @@
|
|||||||
v-for="(location, index) in filteredLocations"
|
v-for="(location, index) in filteredLocations"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
class="list-group-item list-group-item-action"
|
class="list-group-item list-group-item-action"
|
||||||
|
:class="{ active: highlightIndex === index }"
|
||||||
@click="selectLocation(location)"
|
@click="selectLocation(location)"
|
||||||
@mouseenter="highlightIndex = index"
|
@mouseenter="highlightIndex = index"
|
||||||
:class="{ active: highlightIndex === index }"
|
|
||||||
>
|
>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span>{{ location.name }}</span>
|
<span>{{ location.name }}</span>
|
||||||
|
|||||||
@ -80,8 +80,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close btn-close-sm ms-2"
|
class="btn-close btn-close-sm ms-2"
|
||||||
@click="clearSelection"
|
|
||||||
aria-label="Clear selection"
|
aria-label="Clear selection"
|
||||||
|
@click="clearSelection"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-4">{{ isEdit ? "Modifier le groupe" : "Nouveau groupe" }}</h5>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Nom du groupe *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Ex: Entreprises, Particuliers..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.description"
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Description du groupe (optionnel)"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12 d-flex justify-content-end">
|
||||||
|
<soft-button
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
class="me-2"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</soft-button>
|
||||||
|
<soft-button type="submit" color="success" variant="gradient" :disabled="loading">
|
||||||
|
{{ loading ? "Enregistrement..." : isEdit ? "Mettre à jour" : "Créer" }}
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, defineProps, defineEmits } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ name: "", description: "" }),
|
||||||
|
},
|
||||||
|
isEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["submit", "cancel"]);
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
name: props.initialData.name || "",
|
||||||
|
description: props.initialData.description || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialData,
|
||||||
|
(newData) => {
|
||||||
|
if (newData) {
|
||||||
|
formData.value = {
|
||||||
|
name: newData.name || "",
|
||||||
|
description: newData.description || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit("submit", { ...formData.value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-sm-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<soft-button color="success" variant="gradient" @click="$emit('create')">
|
||||||
|
Nouveau Groupe
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<soft-button
|
||||||
|
class="btn-icon ms-2 export"
|
||||||
|
color="dark"
|
||||||
|
variant="outline"
|
||||||
|
data-type="csv"
|
||||||
|
@click="$emit('export')"
|
||||||
|
>
|
||||||
|
<span class="btn-inner--icon">
|
||||||
|
<i class="ni ni-archive-2"></i>
|
||||||
|
</span>
|
||||||
|
<span class="btn-inner--text">Export CSV</span>
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["create", "export"]);
|
||||||
|
</script>
|
||||||
@ -14,16 +14,16 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul
|
<ul
|
||||||
class="nav nav-tabs card-header-tabs"
|
|
||||||
id="defuntTabs"
|
id="defuntTabs"
|
||||||
|
class="nav nav-tabs card-header-tabs"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
>
|
>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button
|
<button
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ active: activeTab === 'details' }"
|
:class="{ active: activeTab === 'details' }"
|
||||||
@click="activeTab = 'details'"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="activeTab = 'details'"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user me-2"></i>
|
<i class="fas fa-user me-2"></i>
|
||||||
Détails
|
Détails
|
||||||
@ -33,8 +33,8 @@
|
|||||||
<button
|
<button
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ active: activeTab === 'documents' }"
|
:class="{ active: activeTab === 'documents' }"
|
||||||
@click="activeTab = 'documents'"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="activeTab = 'documents'"
|
||||||
>
|
>
|
||||||
<i class="fas fa-file-alt me-2"></i>
|
<i class="fas fa-file-alt me-2"></i>
|
||||||
Documents
|
Documents
|
||||||
@ -50,8 +50,8 @@
|
|||||||
<button
|
<button
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ active: activeTab === 'interventions' }"
|
:class="{ active: activeTab === 'interventions' }"
|
||||||
@click="activeTab = 'interventions'"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="activeTab = 'interventions'"
|
||||||
>
|
>
|
||||||
<i class="fas fa-clipboard-list me-2"></i>
|
<i class="fas fa-clipboard-list me-2"></i>
|
||||||
Interventions
|
Interventions
|
||||||
@ -320,8 +320,8 @@
|
|||||||
<soft-button
|
<soft-button
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
@click="saveChanges"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
|
@click="saveChanges"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="isSaving"
|
v-if="isSaving"
|
||||||
@ -333,8 +333,8 @@
|
|||||||
<soft-button
|
<soft-button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="cancelEdit"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
|
@click="cancelEdit"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</soft-button>
|
</soft-button>
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<i class="fas fa-inbox"></i>
|
<i class="fas fa-inbox"></i>
|
||||||
<h3>Aucun défunt trouvé</h3>
|
<h3>Aucun défunt trouvé</h3>
|
||||||
<p>Il semble qu'il n'y ait pas encore de défunts dans cette liste.</p>
|
<p>Il semble qu'il n'y ait pas encore de défunts dans cette liste.</p>
|
||||||
<soft-button @click="addDeceased" class="btn btn-primary">
|
<soft-button class="btn btn-primary" @click="addDeceased">
|
||||||
<i class="fas fa-plus"></i> Ajouter un défunt
|
<i class="fas fa-plus"></i> Ajouter un défunt
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -36,8 +36,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="closeInterventionModal"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
@click="closeInterventionModal"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
v-if="selectedFiles.length === 0"
|
v-if="selectedFiles.length === 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-primary ms-auto"
|
class="btn btn-sm btn-outline-primary ms-auto"
|
||||||
@click="triggerFileInput"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="triggerFileInput"
|
||||||
>
|
>
|
||||||
<i class="fas fa-upload me-2"></i>
|
<i class="fas fa-upload me-2"></i>
|
||||||
Sélectionner
|
Sélectionner
|
||||||
@ -19,8 +19,8 @@
|
|||||||
v-else
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-success ms-auto"
|
class="btn btn-sm btn-success ms-auto"
|
||||||
@click="uploadFiles"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="uploadFiles"
|
||||||
>
|
>
|
||||||
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
||||||
<i v-else class="fas fa-save me-2"></i>
|
<i v-else class="fas fa-save me-2"></i>
|
||||||
@ -36,8 +36,8 @@
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt"
|
||||||
@change="handleFileSelect"
|
|
||||||
class="d-none"
|
class="d-none"
|
||||||
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,8 +51,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-secondary"
|
class="btn btn-sm btn-outline-secondary"
|
||||||
@click="clearSelectedFiles"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="clearSelectedFiles"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times me-1"></i>
|
<i class="fas fa-times me-1"></i>
|
||||||
Tout effacer
|
Tout effacer
|
||||||
@ -79,8 +79,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-danger"
|
class="btn btn-sm btn-outline-danger"
|
||||||
@click="removeSelectedFile(index)"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="removeSelectedFile(index)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -128,8 +128,8 @@
|
|||||||
v-if="documents.length > 0"
|
v-if="documents.length > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-danger ms-auto"
|
class="btn btn-sm btn-outline-danger ms-auto"
|
||||||
@click="confirmDeleteSelected"
|
|
||||||
:disabled="selectedDocumentIds.length === 0 || loading"
|
:disabled="selectedDocumentIds.length === 0 || loading"
|
||||||
|
@click="confirmDeleteSelected"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash me-2"></i>
|
<i class="fas fa-trash me-2"></i>
|
||||||
Supprimer ({{ selectedDocumentIds.length }})
|
Supprimer ({{ selectedDocumentIds.length }})
|
||||||
@ -197,10 +197,10 @@
|
|||||||
<tr v-for="document in documents" :key="document.id">
|
<tr v-for="document in documents" :key="document.id">
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
v-model="selectedDocumentIds"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
:value="document.id"
|
:value="document.id"
|
||||||
v-model="selectedDocumentIds"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -275,8 +275,8 @@
|
|||||||
|
|
||||||
<!-- Edit Label Modal -->
|
<!-- Edit Label Modal -->
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
|
||||||
id="editLabelModal"
|
id="editLabelModal"
|
||||||
|
class="modal fade"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-labelledby="editLabelModalLabel"
|
aria-labelledby="editLabelModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -284,7 +284,7 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="editLabelModalLabel">
|
<h5 id="editLabelModalLabel" class="modal-title">
|
||||||
Modifier le libellé
|
Modifier le libellé
|
||||||
</h5>
|
</h5>
|
||||||
<button
|
<button
|
||||||
@ -300,10 +300,10 @@
|
|||||||
>Libellé du document</label
|
>Libellé du document</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="documentLabel"
|
id="documentLabel"
|
||||||
v-model="editingDocument.label"
|
v-model="editingDocument.label"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
placeholder="Entrez un libellé personnalisé"
|
placeholder="Entrez un libellé personnalisé"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -319,8 +319,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="saveDocumentLabel"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="saveDocumentLabel"
|
||||||
>
|
>
|
||||||
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
|
|||||||
@ -40,8 +40,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm bg-gradient-secondary"
|
class="btn btn-sm bg-gradient-secondary"
|
||||||
@click="toggleEditMode"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="toggleEditMode"
|
||||||
>
|
>
|
||||||
{{ editMode ? "Sauvegarder" : "Modifier" }}
|
{{ editMode ? "Sauvegarder" : "Modifier" }}
|
||||||
</button>
|
</button>
|
||||||
@ -51,16 +51,16 @@
|
|||||||
<!-- Colonne gauche -->
|
<!-- Colonne gauche -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Nom du défunt"
|
|
||||||
v-model="localIntervention.defuntName"
|
v-model="localIntervention.defuntName"
|
||||||
|
label="Nom du défunt"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SoftInput
|
<SoftInput
|
||||||
|
v-model="localIntervention.date"
|
||||||
label="Date de l'intervention"
|
label="Date de l'intervention"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
v-model="localIntervention.date"
|
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
@ -76,22 +76,22 @@
|
|||||||
<!-- Colonne droite -->
|
<!-- Colonne droite -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Durée prévue"
|
|
||||||
v-model="localIntervention.duree"
|
v-model="localIntervention.duree"
|
||||||
|
label="Durée prévue"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Type de cérémonie"
|
|
||||||
v-model="localIntervention.title"
|
v-model="localIntervention.title"
|
||||||
|
label="Type de cérémonie"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SoftInput
|
<SoftInput
|
||||||
label="Contact familial"
|
|
||||||
v-model="localIntervention.contactFamilial"
|
v-model="localIntervention.contactFamilial"
|
||||||
|
label="Contact familial"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
@ -103,9 +103,9 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="mb-3">Description</h6>
|
<h6 class="mb-3">Description</h6>
|
||||||
<SoftInput
|
<SoftInput
|
||||||
|
v-model="localIntervention.description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
v-model="localIntervention.description"
|
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
placeholder="Description détaillée de l'intervention..."
|
placeholder="Description détaillée de l'intervention..."
|
||||||
/>
|
/>
|
||||||
@ -119,16 +119,16 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm bg-gradient-danger me-2"
|
class="btn btn-sm bg-gradient-danger me-2"
|
||||||
@click="resetChanges"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="resetChanges"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm bg-gradient-success"
|
class="btn btn-sm bg-gradient-success"
|
||||||
@click="saveChanges"
|
|
||||||
:disabled="loading || !hasChanges"
|
:disabled="loading || !hasChanges"
|
||||||
|
@click="saveChanges"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-2"></i>Sauvegarder
|
<i class="fas fa-save me-2"></i>Sauvegarder
|
||||||
</button>
|
</button>
|
||||||
@ -159,8 +159,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="showTeamModal = false"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
@click="showTeamModal = false"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@ -43,8 +43,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
@click="startEditing"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="startEditing"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit me-1"></i>
|
<i class="fas fa-edit me-1"></i>
|
||||||
Changer le lieu
|
Changer le lieu
|
||||||
@ -56,8 +56,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-secondary"
|
class="btn btn-sm btn-outline-secondary"
|
||||||
@click="cancelEditing"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
@click="cancelEditing"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times me-1"></i>
|
<i class="fas fa-times me-1"></i>
|
||||||
Annuler
|
Annuler
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="supplier-actions">
|
<div class="supplier-actions">
|
||||||
<button @click="viewSupplier" class="btn btn-outline btn-sm">
|
<button class="btn btn-outline btn-sm" @click="viewSupplier">
|
||||||
Voir le fournisseur
|
Voir le fournisseur
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,33 +3,42 @@
|
|||||||
<!-- Product Search -->
|
<!-- Product Search -->
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Rechercher un produit..."
|
placeholder="Rechercher un produit..."
|
||||||
:value="modelValue.product_name"
|
:value="modelValue.product_name"
|
||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
/>
|
/>
|
||||||
<div v-if="showResults && searchResults.length > 0" class="search-results shadow-sm border rounded">
|
<div
|
||||||
<ul class="list-group list-group-flush">
|
v-if="showResults && searchResults.length > 0"
|
||||||
<li
|
class="search-results shadow-sm border rounded"
|
||||||
v-for="product in searchResults"
|
>
|
||||||
:key="product.id"
|
<ul class="list-group list-group-flush">
|
||||||
class="list-group-item list-group-item-action cursor-pointer"
|
<li
|
||||||
@click="selectProduct(product)"
|
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 v-if="product.reference" class="text-muted"
|
||||||
|
>Ref: {{ product.reference }}</small
|
||||||
>
|
>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
</div>
|
||||||
<div>
|
<span class="badge bg-secondary"
|
||||||
<span class="fw-bold">{{ product.nom }}</span>
|
>{{ product.stock_actuel }} en stock</span
|
||||||
<br>
|
>
|
||||||
<small class="text-muted" v-if="product.reference">Ref: {{ product.reference }}</small>
|
</div>
|
||||||
</div>
|
<small class="text-muted d-block mt-1"
|
||||||
<span class="badge bg-secondary">{{ product.stock_actuel }} en stock</span>
|
>{{ formatCurrency(product.prix_unitaire) }} HT</small
|
||||||
</div>
|
>
|
||||||
<small class="text-muted d-block mt-1">{{ formatCurrency(product.prix_unitaire) }} HT</small>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -47,22 +56,24 @@
|
|||||||
|
|
||||||
<!-- Unit Price -->
|
<!-- Unit Price -->
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Prix"
|
placeholder="Prix"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
:value="modelValue.unit_price"
|
:value="modelValue.unit_price"
|
||||||
@input="updatePrice($event.target.value)"
|
@input="updatePrice($event.target.value)"
|
||||||
/>
|
/>
|
||||||
<span class="input-group-text">€</span>
|
<span class="input-group-text">€</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Line -->
|
<!-- Total Line -->
|
||||||
<div class="col-2 text-end">
|
<div class="col-2 text-end">
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(lineTotal) }}</span>
|
<span class="text-sm font-weight-bold">{{
|
||||||
|
formatCurrency(lineTotal)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Action -->
|
<!-- Delete Action -->
|
||||||
@ -80,25 +91,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, defineProps, defineEmits } from 'vue';
|
import { ref, computed, watch, defineProps, defineEmits } from "vue";
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import { useProductStore } from '@/stores/productStore';
|
import { useProductStore } from "@/stores/productStore";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
product_id: null,
|
product_id: null,
|
||||||
product_name: '',
|
product_name: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
tva: 20
|
tva: 20,
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'remove']);
|
const emit = defineEmits(["update:modelValue", "remove"]);
|
||||||
|
|
||||||
const productStore = useProductStore();
|
const productStore = useProductStore();
|
||||||
const searchResults = ref([]);
|
const searchResults = ref([]);
|
||||||
@ -106,109 +117,112 @@ const showResults = ref(false);
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
const lineTotal = computed(() => {
|
const lineTotal = computed(() => {
|
||||||
return props.modelValue.quantity * props.modelValue.unit_price;
|
return props.modelValue.quantity * props.modelValue.unit_price;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSearchInput = (event) => {
|
const onSearchInput = (event) => {
|
||||||
const query = event.target.value;
|
const query = event.target.value;
|
||||||
// Clear product_id when user types, as it's no longer the selected product
|
// Clear product_id when user types, as it's no longer the selected product
|
||||||
emit('update:modelValue', {
|
emit("update:modelValue", {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
product_name: query,
|
product_name: query,
|
||||||
product_id: null
|
product_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
|
||||||
|
|
||||||
if (query.length < 2) {
|
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;
|
showResults.value = false;
|
||||||
return;
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Search error:", e);
|
||||||
}
|
}
|
||||||
|
}, 300);
|
||||||
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) => {
|
const selectProduct = (product) => {
|
||||||
emit('update:modelValue', {
|
emit("update:modelValue", {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
product_name: product.nom,
|
product_name: product.nom,
|
||||||
unit_price: product.prix_unitaire,
|
unit_price: product.prix_unitaire,
|
||||||
description: product.description || product.nom // Use description if available
|
description: product.description || product.nom, // Use description if available
|
||||||
});
|
});
|
||||||
showResults.value = false;
|
showResults.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ... existing updateQuantity and updatePrice ...
|
// ... existing updateQuantity and updatePrice ...
|
||||||
const updateQuantity = (val) => {
|
const updateQuantity = (val) => {
|
||||||
updateField('quantity', parseFloat(val));
|
updateField("quantity", parseFloat(val));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePrice = (val) => {
|
const updatePrice = (val) => {
|
||||||
updateField('unit_price', parseFloat(val));
|
updateField("unit_price", parseFloat(val));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
const updateField = (field, value) => {
|
||||||
emit('update:modelValue', {
|
emit("update:modelValue", {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
[field]: value
|
[field]: value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click outside handling
|
// Click outside handling
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
const searchContainer = event.target.closest('.position-relative');
|
const searchContainer = event.target.closest(".position-relative");
|
||||||
if (!searchContainer && showResults.value) {
|
if (!searchContainer && showResults.value) {
|
||||||
showResults.value = false;
|
showResults.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
import { onMounted, onUnmounted } from 'vue';
|
import { onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener("click", handleClickOutside);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-results {
|
.search-results {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
background: white;
|
background: white;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
}
|
}
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-3">Détails de paiement</h6>
|
||||||
|
<div
|
||||||
|
class="card card-body border card-plain border-radius-lg d-flex align-items-center flex-row"
|
||||||
|
>
|
||||||
|
<i class="fas fa-university fa-lg me-3 text-secondary"></i>
|
||||||
|
<h6 class="mb-0">Virement Bancaire</h6>
|
||||||
|
<soft-button
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
class="btn-icon-only btn-rounded mb-0 ms-2 btn-sm d-flex align-items-center justify-content-center ms-auto"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="bottom"
|
||||||
|
title
|
||||||
|
data-bs-original-title="Informations bancaires"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info" aria-hidden="true"></i>
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
<h6 class="mb-3 mt-4">Informations Client</h6>
|
||||||
|
<ul class="list-group">
|
||||||
|
<li
|
||||||
|
class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h6 class="mb-3 text-sm">{{ clientName }}</h6>
|
||||||
|
<span class="mb-2 text-xs">
|
||||||
|
Email:
|
||||||
|
<span class="text-dark font-weight-bold ms-2">{{
|
||||||
|
clientEmail
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs">
|
||||||
|
Téléphone:
|
||||||
|
<span class="text-dark ms-2 font-weight-bold">{{
|
||||||
|
clientPhone || "Non renseigné"
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
clientName: String,
|
||||||
|
clientEmail: String,
|
||||||
|
clientPhone: String,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card-header p-3 pb-0">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6>Détails du Devis</h6>
|
||||||
|
<p class="text-sm mb-0">
|
||||||
|
Devis n°
|
||||||
|
<b>{{ reference }}</b> du
|
||||||
|
<b>{{ formatDate(date) }}</b>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
Code:
|
||||||
|
<b>{{ code || "N/A" }}</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<soft-button
|
||||||
|
color="secondary"
|
||||||
|
variant="gradient"
|
||||||
|
class="ms-auto mb-0"
|
||||||
|
@click="$emit('download')"
|
||||||
|
>Télécharger PDF</soft-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
reference: String,
|
||||||
|
date: String,
|
||||||
|
code: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["download"]);
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
return new Date(dateString).toLocaleDateString("fr-FR");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -1,68 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card shadow-none border h-100">
|
<div class="card shadow-none border h-100">
|
||||||
<div class="card-header pb-0 p-3">
|
<div class="card-header pb-0 p-3">
|
||||||
<h6 class="mb-0">Informations</h6>
|
<h6 class="mb-0">Informations</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
<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="text-sm font-weight-bold text-dark">Référence:</span>
|
||||||
<span class="ms-2 text-sm">{{ reference }}</span>
|
<span class="ms-2 text-sm">{{ reference }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
<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="text-sm font-weight-bold text-dark">Client:</span>
|
||||||
<span class="ms-2 text-sm">{{ clientName }}</span>
|
<span class="ms-2 text-sm">{{ clientName }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
<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="text-sm font-weight-bold text-dark">Date:</span>
|
||||||
<span class="ms-2 text-sm">{{ formatDate(quoteDate) }}</span>
|
<span class="ms-2 text-sm">{{ formatDate(quoteDate) }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
<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="text-sm font-weight-bold text-dark">Validité:</span>
|
||||||
<span class="ms-2 text-sm">{{ formatDate(validUntil) }}</span>
|
<span class="ms-2 text-sm">{{ formatDate(validUntil) }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item px-0 pb-3 pt-0 border-0">
|
<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="text-sm font-weight-bold text-dark">Statut:</span>
|
||||||
<span class="ms-2 badge badge-sm" :class="statusBadgeClass">{{ statusLabel }}</span>
|
<span class="ms-2 badge badge-sm" :class="statusBadgeClass">{{
|
||||||
</li>
|
statusLabel
|
||||||
</ul>
|
}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineProps } from 'vue';
|
import { computed, defineProps } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
reference: String,
|
reference: String,
|
||||||
clientName: String,
|
clientName: String,
|
||||||
quoteDate: String,
|
quoteDate: String,
|
||||||
validUntil: String,
|
validUntil: String,
|
||||||
status: String
|
status: String,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return "-";
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR');
|
return new Date(dateString).toLocaleDateString("fr-FR");
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeClass = computed(() => {
|
const statusBadgeClass = computed(() => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case 'brouillon': return 'bg-gradient-secondary';
|
case "brouillon":
|
||||||
case 'envoye': return 'bg-gradient-info';
|
return "bg-gradient-secondary";
|
||||||
case 'accepte': return 'bg-gradient-success';
|
case "envoye":
|
||||||
case 'refuse': return 'bg-gradient-danger';
|
return "bg-gradient-info";
|
||||||
default: return 'bg-gradient-secondary';
|
case "accepte":
|
||||||
}
|
return "bg-gradient-success";
|
||||||
|
case "refuse":
|
||||||
|
return "bg-gradient-danger";
|
||||||
|
default:
|
||||||
|
return "bg-gradient-secondary";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const statusLabel = computed(() => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case 'brouillon': return 'Brouillon';
|
case "brouillon":
|
||||||
case 'envoye': return 'Envoyé';
|
return "Brouillon";
|
||||||
case 'accepte': return 'Accepté';
|
case "envoye":
|
||||||
case 'refuse': return 'Refusé';
|
return "Envoyé";
|
||||||
default: return props.status;
|
case "accepte":
|
||||||
}
|
return "Accepté";
|
||||||
|
case "refuse":
|
||||||
|
return "Refusé";
|
||||||
|
default:
|
||||||
|
return props.status;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,36 +3,78 @@
|
|||||||
<table class="table align-items-center mb-0">
|
<table class="table align-items-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Produit</th>
|
<th
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Qté</th>
|
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
|
||||||
<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>
|
Produit
|
||||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2 text-end">Total HT</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="line in lines" :key="line.id">
|
<tr v-for="line in lines" :key="line.id">
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex flex-column justify-content-center">
|
<div class="d-flex flex-column justify-content-center">
|
||||||
<h6 class="mb-0 text-sm">{{ line.product_name || line.description }}</h6>
|
<h6 class="mb-0 text-sm">
|
||||||
<p class="text-xs text-secondary mb-0" v-if="line.product && line.product.reference">{{ line.product.reference }}</p>
|
{{ line.product_name || line.description }}
|
||||||
|
</h6>
|
||||||
|
<p
|
||||||
|
v-if="line.product && line.product.reference"
|
||||||
|
class="text-xs text-secondary mb-0"
|
||||||
|
>
|
||||||
|
{{ line.product.reference }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ line.packages_qty || line.units_qty || line.quantity || line.qty_base }}</p>
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{
|
||||||
|
line.packages_qty ||
|
||||||
|
line.units_qty ||
|
||||||
|
line.quantity ||
|
||||||
|
line.qty_base
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ formatCurrency(line.unit_price) }}</p>
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ formatCurrency(line.unit_price) }}
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ line.discount_pct }}%</p>
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ line.discount_pct }}%
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<p class="text-xs font-weight-bold mb-0">{{ formatCurrency(line.total_ht) }}</p>
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ formatCurrency(line.total_ht) }}
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!lines || lines.length === 0">
|
<tr v-if="!lines || lines.length === 0">
|
||||||
<td colspan="5" class="text-center text-sm py-3">Aucune ligne de produit</td>
|
<td colspan="5" class="text-center text-sm py-3">
|
||||||
|
Aucune ligne de produit
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -40,16 +82,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
lines: {
|
lines: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => [],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-sm-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<soft-button color="success" variant="gradient" @click="$emit('create')">
|
||||||
|
Nouveau Devis
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="dropdown d-inline">
|
||||||
|
<soft-button
|
||||||
|
id="navbarDropdownMenuLink2"
|
||||||
|
color="dark"
|
||||||
|
variant="outline"
|
||||||
|
class="dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Filtrer
|
||||||
|
</soft-button>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu dropdown-menu-lg-start px-2 py-3"
|
||||||
|
aria-labelledby="navbarDropdownMenuLink2"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('filter', 'envoye')"
|
||||||
|
>
|
||||||
|
Statut: Envoyé
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('filter', 'accepte')"
|
||||||
|
>
|
||||||
|
Statut: Accepté
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('filter', 'brouillon')"
|
||||||
|
>
|
||||||
|
Statut: Brouillon
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item border-radius-md"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('filter', 'refuse')"
|
||||||
|
>
|
||||||
|
Statut: Refusé
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr class="horizontal dark my-2" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="dropdown-item border-radius-md text-danger"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('filter', null)"
|
||||||
|
>
|
||||||
|
Retirer Filtres
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<soft-button
|
||||||
|
class="btn-icon ms-2 export"
|
||||||
|
color="dark"
|
||||||
|
variant="outline"
|
||||||
|
data-type="csv"
|
||||||
|
@click="$emit('export')"
|
||||||
|
>
|
||||||
|
<span class="btn-inner--icon">
|
||||||
|
<i class="ni ni-archive-2"></i>
|
||||||
|
</span>
|
||||||
|
<span class="btn-inner--text">Export CSV</span>
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["create", "filter", "export"]);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-3">Résumé du Devis</h6>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="mb-2 text-sm">Total HT:</span>
|
||||||
|
<span class="text-dark font-weight-bold ms-2">{{
|
||||||
|
formatCurrency(ht)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-sm">TVA:</span>
|
||||||
|
<span class="text-dark ms-2 font-weight-bold">{{
|
||||||
|
formatCurrency(tva)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<span class="mb-2 text-lg">Total TTC:</span>
|
||||||
|
<span class="text-dark text-lg ms-2 font-weight-bold">{{
|
||||||
|
formatCurrency(ttc)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
ht: Number,
|
||||||
|
tva: Number,
|
||||||
|
ttc: Number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
110
thanasoft-front/src/components/molecules/Quote/QuoteTimeline.vue
Normal file
110
thanasoft-front/src/components/molecules/Quote/QuoteTimeline.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-3">Suivi du Devis</h6>
|
||||||
|
<div class="timeline-scrollable">
|
||||||
|
<div class="timeline timeline-one-side">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in history"
|
||||||
|
:key="index"
|
||||||
|
class="timeline-block mb-3"
|
||||||
|
>
|
||||||
|
<span class="timeline-step">
|
||||||
|
<i :class="getStatusIcon(item.new_status)"></i>
|
||||||
|
</span>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h6 class="text-dark text-sm font-weight-bold mb-0">
|
||||||
|
{{ getStatusLabel(item.new_status) }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-secondary font-weight-bold text-xs mt-1 mb-0">
|
||||||
|
{{ formatDate(item.changed_at) }}
|
||||||
|
<span v-if="item.changed_by">par {{ item.changed_by }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="item.comment" class="text-sm mt-2 mb-0">
|
||||||
|
{{ item.comment }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="history.length === 0" class="text-sm text-secondary">
|
||||||
|
Aucun historique disponible.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
history: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const options = {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
};
|
||||||
|
return date.toLocaleDateString("fr-FR", options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
const map = {
|
||||||
|
brouillon: "ni ni-bell-55 text-secondary",
|
||||||
|
envoye: "ni ni-email-83 text-info",
|
||||||
|
accepte: "ni ni-check-bold text-success text-gradient",
|
||||||
|
refuse: "ni ni-fat-remove text-danger text-gradient",
|
||||||
|
expire: "ni ni-time-alarm text-warning",
|
||||||
|
annule: "ni ni-simple-remove text-danger",
|
||||||
|
facture: "ni ni-money-coins text-success",
|
||||||
|
};
|
||||||
|
return map[status] || "ni ni-bell-55 text-secondary";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
const labels = {
|
||||||
|
brouillon: "Devis créé (Brouillon)",
|
||||||
|
envoye: "Devis envoyé",
|
||||||
|
accepte: "Devis accepté",
|
||||||
|
refuse: "Devis refusé",
|
||||||
|
expire: "Devis expiré",
|
||||||
|
annule: "Devis annulé",
|
||||||
|
facture: "Devis facturé",
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-scrollable {
|
||||||
|
max-height: 300px; /* Approximately 3 timeline items */
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling */
|
||||||
|
.timeline-scrollable::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scrollable::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scrollable::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scrollable::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,35 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card shadow-none border bg-gray-100">
|
<div class="card shadow-none border bg-gray-100">
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span class="text-sm text-secondary">Total HT</span>
|
<span class="text-sm text-secondary">Total HT</span>
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.ht) }}</span>
|
<span class="text-sm font-weight-bold">{{
|
||||||
</div>
|
formatCurrency(totals.ht)
|
||||||
<div class="d-flex justify-content-between mb-2">
|
}}</span>
|
||||||
<span class="text-sm text-secondary">TVA</span>
|
</div>
|
||||||
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.tva) }}</span>
|
<div class="d-flex justify-content-between mb-2">
|
||||||
</div>
|
<span class="text-sm text-secondary">TVA</span>
|
||||||
<hr class="horizontal dark my-2">
|
<span class="text-sm font-weight-bold">{{
|
||||||
<div class="d-flex justify-content-between">
|
formatCurrency(totals.tva)
|
||||||
<span class="text-base text-dark font-weight-bold">Total TTC</span>
|
}}</span>
|
||||||
<span class="text-base text-primary font-weight-bold">{{ formatCurrency(totals.ttc) }}</span>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
totals: {
|
totals: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => ({ ht: 0, tva: 0, ttc: 0 })
|
default: () => ({ ht: 0, tva: 0, ttc: 0 }),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -110,10 +110,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input
|
<input
|
||||||
|
id="activeSwitch"
|
||||||
v-model="formData.active"
|
v-model="formData.active"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="activeSwitch"
|
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="activeSwitch">
|
<label class="form-check-label" for="activeSwitch">
|
||||||
Catégorie active
|
Catégorie active
|
||||||
|
|||||||
@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="client-group-list" class="table table-flush">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Date de création</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="group in data" :key="group.id">
|
||||||
|
<!-- Name -->
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<soft-checkbox class="me-2" />
|
||||||
|
<p class="text-sm font-weight-bold ms-2 mb-0">
|
||||||
|
{{ group.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<td class="text-sm">
|
||||||
|
<span class="text-secondary">
|
||||||
|
{{ group.description || "Aucune description" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Created At -->
|
||||||
|
<td class="text-sm font-weight-bold">
|
||||||
|
<span class="my-2 text-xs">{{ formatDate(group.created_at) }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-link text-secondary mb-0 px-2"
|
||||||
|
:data-id="group.id"
|
||||||
|
data-action="view"
|
||||||
|
title="Voir le groupe"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-link text-secondary mb-0 px-2"
|
||||||
|
:data-id="group.id"
|
||||||
|
data-action="edit"
|
||||||
|
title="Modifier le groupe"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit text-xs" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-link text-danger mb-0 px-2"
|
||||||
|
:data-id="group.id"
|
||||||
|
data-action="delete"
|
||||||
|
title="Supprimer le groupe"
|
||||||
|
>
|
||||||
|
<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 SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["view", "edit", "delete"]);
|
||||||
|
|
||||||
|
const dataTableInstance = ref(null);
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
const options = {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
return new Date(dateString).toLocaleDateString("fr-FR", options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeDataTable = () => {
|
||||||
|
const table = document.getElementById("client-group-list");
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
if (dataTableInstance.value) {
|
||||||
|
dataTableInstance.value.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTableInstance.value = new DataTable(table, {
|
||||||
|
searchable: true,
|
||||||
|
fixedHeight: false,
|
||||||
|
perPage: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event delegation for action buttons
|
||||||
|
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 === "edit") {
|
||||||
|
emit("edit", parseInt(id));
|
||||||
|
} else if (action === "delete") {
|
||||||
|
emit("delete", parseInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.data && props.data.length > 0) {
|
||||||
|
initializeDataTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
(newData) => {
|
||||||
|
if (newData && newData.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeDataTable();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (dataTableInstance.value) {
|
||||||
|
dataTableInstance.value.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -1,181 +1,125 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-container">
|
<div class="card mt-4">
|
||||||
<!-- Loading State -->
|
<div class="table-responsive">
|
||||||
<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">
|
<table id="quote-list" class="table table-flush">
|
||||||
<thead class="thead-light">
|
<thead class="thead-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Référence</th>
|
<th>Id</th>
|
||||||
<th>Client</th>
|
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Validité</th>
|
|
||||||
<th>Total TTC</th>
|
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Produit</th>
|
||||||
|
<th>Total TTC</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="quote in data" :key="quote.id">
|
<tr v-for="quote in data" :key="quote.id">
|
||||||
<!-- Reference -->
|
<!-- Id (Reference) -->
|
||||||
<td class="text-sm font-weight-bold">
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<soft-checkbox class="me-2" />
|
<soft-checkbox class="me-2" />
|
||||||
<span class="my-2 text-xs">{{ quote.reference }}</span>
|
<p class="text-xs font-weight-bold ms-2 mb-0">
|
||||||
</div>
|
{{ quote.reference }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Client -->
|
<!-- Date -->
|
||||||
<td class="text-sm font-weight-bold">
|
<td class="font-weight-bold">
|
||||||
<div class="d-flex px-2 py-1">
|
<span class="my-2 text-xs">{{
|
||||||
<div v-if="quote.client">
|
formatDate(quote.quote_date)
|
||||||
<soft-avatar
|
}}</span>
|
||||||
: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>
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<td class="text-xs font-weight-bold">
|
<td class="text-xs font-weight-bold">
|
||||||
<soft-badge :color="getStatusColor(quote.status)" variant="gradient" size="sm">
|
<div class="d-flex align-items-center">
|
||||||
{{ getStatusLabel(quote.status) }}
|
<soft-button
|
||||||
</soft-badge>
|
:color="getStatusColor(quote.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(quote.status)"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
</soft-button>
|
||||||
|
<span>{{ getStatusLabel(quote.status) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Client -->
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<soft-avatar
|
||||||
|
:img="getRandomAvatar()"
|
||||||
|
class="me-2"
|
||||||
|
size="xs"
|
||||||
|
alt="user image"
|
||||||
|
circular
|
||||||
|
/>
|
||||||
|
<span>{{
|
||||||
|
quote.client ? quote.client.name : "Client Inconnu"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Product -->
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
<span class="my-2 text-xs">
|
||||||
|
{{ getProductSummary(quote.lines) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Revenue (Total TTC) -->
|
||||||
|
<td class="text-xs font-weight-bold">
|
||||||
|
<span class="my-2 text-xs">{{
|
||||||
|
formatCurrency(quote.total_ttc)
|
||||||
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td class="text-sm">
|
<td class="text-xs font-weight-bold">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center">
|
||||||
<soft-button
|
<button
|
||||||
color="info"
|
class="btn btn-link text-secondary mb-0 px-2"
|
||||||
variant="outline"
|
:data-id="quote.id"
|
||||||
title="Voir le devis"
|
data-action="view"
|
||||||
:data-id="quote.id"
|
title="Voir le devis"
|
||||||
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 text-xs" aria-hidden="true"></i>
|
||||||
>
|
</button>
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<button
|
||||||
</soft-button>
|
class="btn btn-link text-danger mb-0 px-2"
|
||||||
<soft-button
|
:data-id="quote.id"
|
||||||
color="primary"
|
data-action="delete"
|
||||||
variant="outline"
|
title="Supprimer le devis"
|
||||||
title="Modifier le devis"
|
>
|
||||||
:data-id="quote.id"
|
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
</button>
|
||||||
@click="$emit('edit', quote.id)"
|
</div>
|
||||||
>
|
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, onUnmounted, defineProps, defineEmits } from "vue";
|
import {
|
||||||
|
ref,
|
||||||
|
onMounted,
|
||||||
|
watch,
|
||||||
|
onUnmounted,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
} from "vue";
|
||||||
import { DataTable } from "simple-datatables";
|
import { DataTable } from "simple-datatables";
|
||||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||||
import SoftBadge from "@/components/SoftBadge.vue";
|
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||||
|
|
||||||
// Sample avatar images
|
// Sample avatar images
|
||||||
import img1 from "@/assets/img/team-2.jpg";
|
import img1 from "@/assets/img/team-2.jpg";
|
||||||
@ -187,7 +131,7 @@ import img6 from "@/assets/img/ivana-squares.jpg";
|
|||||||
|
|
||||||
const avatarImages = [img1, img2, img3, img4, img5, img6];
|
const avatarImages = [img1, img2, img3, img4, img5, img6];
|
||||||
|
|
||||||
const emit = defineEmits(["view", "edit", "delete"]);
|
const emit = defineEmits(["view", "delete"]);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@ -198,53 +142,80 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
skeletonRows: {
|
|
||||||
type: Number,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataTableInstance = ref(null);
|
const dataTableInstance = ref(null);
|
||||||
|
|
||||||
const getRandomAvatar = () => {
|
const getRandomAvatar = () => {
|
||||||
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
||||||
return avatarImages[randomIndex];
|
return avatarImages[randomIndex];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return "-";
|
if (!dateString) return "-";
|
||||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
const options = {
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR', options);
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
};
|
||||||
|
// Note: The date string from Laravel might not have time, assume start of day if so
|
||||||
|
return new Date(dateString).toLocaleDateString("fr-FR", options);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Map status to colors and icons
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
const colors = {
|
const map = {
|
||||||
brouillon: 'secondary',
|
brouillon: "secondary",
|
||||||
envoye: 'info',
|
envoye: "info",
|
||||||
accepte: 'success',
|
accepte: "success",
|
||||||
refuse: 'danger',
|
refuse: "danger",
|
||||||
expire: 'warning',
|
expire: "warning",
|
||||||
annule: 'danger'
|
annule: "danger",
|
||||||
};
|
};
|
||||||
return colors[status] || 'secondary';
|
return map[status] || "secondary";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
const map = {
|
||||||
|
brouillon: "fas fa-pen",
|
||||||
|
envoye: "fas fa-paper-plane",
|
||||||
|
accepte: "fas fa-check",
|
||||||
|
refuse: "fas fa-times",
|
||||||
|
expire: "fas fa-clock",
|
||||||
|
annule: "fas fa-ban",
|
||||||
|
};
|
||||||
|
return map[status] || "fas fa-info";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status) => {
|
const getStatusLabel = (status) => {
|
||||||
const labels = {
|
const labels = {
|
||||||
brouillon: 'Brouillon',
|
brouillon: "Brouillon",
|
||||||
envoye: 'Envoyé',
|
envoye: "Envoyé",
|
||||||
accepte: 'Accepté',
|
accepte: "Payé", // "Paid" in the example, assuming Accepted = Paid contextually or mapped
|
||||||
refuse: 'Refusé',
|
refuse: "Refusé",
|
||||||
expire: 'Expiré',
|
expire: "Expiré",
|
||||||
annule: 'Annulé'
|
annule: "Annulé",
|
||||||
};
|
};
|
||||||
return labels[status] || status;
|
return labels[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getProductSummary = (lines) => {
|
||||||
|
if (!lines || lines.length === 0) return "Aucun produit";
|
||||||
|
const firstProduct =
|
||||||
|
lines[0].product_name || lines[0].description || "Produit";
|
||||||
|
if (lines.length > 1) {
|
||||||
|
return `${firstProduct} +${lines.length - 1} autre(s)`;
|
||||||
|
}
|
||||||
|
return firstProduct;
|
||||||
|
};
|
||||||
|
|
||||||
const initializeDataTable = () => {
|
const initializeDataTable = () => {
|
||||||
if (dataTableInstance.value) {
|
if (dataTableInstance.value) {
|
||||||
@ -256,18 +227,41 @@ const initializeDataTable = () => {
|
|||||||
if (dataTableEl) {
|
if (dataTableEl) {
|
||||||
dataTableInstance.value = new DataTable(dataTableEl, {
|
dataTableInstance.value = new DataTable(dataTableEl, {
|
||||||
searchable: true,
|
searchable: true,
|
||||||
fixedHeight: true,
|
fixedHeight: false, // Changed from true to false as per request
|
||||||
perPage: 10,
|
perPageSelect: false, // Changed as per request
|
||||||
labels: {
|
|
||||||
placeholder: "Rechercher...",
|
|
||||||
perPage: "{select} entrées par page",
|
|
||||||
noRows: "Aucune entrée trouvée",
|
|
||||||
info: "Affichage de {start} à {end} sur {rows} entrées",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.loading && props.data.length > 0) {
|
||||||
|
initializeDataTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation
|
||||||
|
const table = document.getElementById("quote-list");
|
||||||
|
if (table) {
|
||||||
|
table.addEventListener("click", (event) => {
|
||||||
|
// Check if the click is on a button or an icon inside a button
|
||||||
|
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") {
|
||||||
|
console.log("Delegated View click for id:", id);
|
||||||
|
emit("view", parseInt(id));
|
||||||
|
} else if (action === "delete") {
|
||||||
|
console.log("Delegated Delete click for id:", id);
|
||||||
|
emit("delete", parseInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.data,
|
() => props.data,
|
||||||
() => {
|
() => {
|
||||||
@ -285,84 +279,4 @@ onUnmounted(() => {
|
|||||||
dataTableInstance.value.destroy();
|
dataTableInstance.value.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!props.loading && props.data.length > 0) {
|
|
||||||
initializeDataTable();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0">Documents du praticien</h6>
|
<h6 class="mb-0">Documents du praticien</h6>
|
||||||
<button @click="showAddModal = true" class="btn btn-sm btn-primary">
|
<button class="btn btn-sm btn-primary" @click="showAddModal = true">
|
||||||
<i class="fas fa-plus me-1"></i>
|
<i class="fas fa-plus me-1"></i>
|
||||||
Ajouter un document
|
Ajouter un document
|
||||||
</button>
|
</button>
|
||||||
@ -47,22 +47,22 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="btn-group w-100" role="group">
|
<div class="btn-group w-100" role="group">
|
||||||
<button
|
<button
|
||||||
@click="downloadDocument(document)"
|
|
||||||
class="btn btn-outline-primary btn-sm"
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
@click="downloadDocument(document)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-download me-1"></i>
|
<i class="fas fa-download me-1"></i>
|
||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="editDocument(document)"
|
|
||||||
class="btn btn-outline-info btn-sm"
|
class="btn btn-outline-info btn-sm"
|
||||||
|
@click="editDocument(document)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit me-1"></i>
|
<i class="fas fa-edit me-1"></i>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="deleteDocument(document.id)"
|
|
||||||
class="btn btn-outline-danger btn-sm"
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
@click="deleteDocument(document.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash me-1"></i>
|
<i class="fas fa-trash me-1"></i>
|
||||||
Supprimer
|
Supprimer
|
||||||
@ -85,7 +85,7 @@
|
|||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Commencez par ajouter des documents pour ce praticien.
|
Commencez par ajouter des documents pour ce praticien.
|
||||||
</p>
|
</p>
|
||||||
<button @click="showAddModal = true" class="btn btn-primary">
|
<button class="btn btn-primary" @click="showAddModal = true">
|
||||||
<i class="fas fa-plus me-1"></i>
|
<i class="fas fa-plus me-1"></i>
|
||||||
Ajouter le premier document
|
Ajouter le premier document
|
||||||
</button>
|
</button>
|
||||||
@ -116,7 +116,7 @@
|
|||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form @submit.prevent="saveDocument" novalidate>
|
<form novalidate @submit.prevent="saveDocument">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -220,8 +220,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="saveDocument"
|
|
||||||
:disabled="isDocumentLoading"
|
:disabled="isDocumentLoading"
|
||||||
|
@click="saveDocument"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="isDocumentLoading"
|
v-if="isDocumentLoading"
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form @submit.prevent="handleSubmit" novalidate>
|
<form novalidate @submit.prevent="handleSubmit">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Personal Information -->
|
<!-- Personal Information -->
|
||||||
<div class="col-12 mb-4">
|
<div class="col-12 mb-4">
|
||||||
@ -184,8 +184,8 @@
|
|||||||
<soft-button
|
<soft-button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-light me-3"
|
class="btn btn-light me-3"
|
||||||
@click="resetForm"
|
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@click="resetForm"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</soft-button>
|
</soft-button>
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
<h6 class="mb-0">Aperçu de l'employé</h6>
|
<h6 class="mb-0">Aperçu de l'employé</h6>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@click="$emit('view-info-tab')"
|
|
||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
@click="$emit('view-info-tab')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit me-1"></i>
|
<i class="fas fa-edit me-1"></i>
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="close"
|
|
||||||
aria-label="Fermer"
|
aria-label="Fermer"
|
||||||
|
@click="close"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@ -2,19 +2,19 @@
|
|||||||
<div class="practitioner-search-input">
|
<div class="practitioner-search-input">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Rechercher un praticien..."
|
placeholder="Rechercher un praticien..."
|
||||||
v-model="searchQuery"
|
:disabled="loading"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
:disabled="loading"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleSearch"
|
|
||||||
:disabled="loading || !searchQuery.trim()"
|
:disabled="loading || !searchQuery.trim()"
|
||||||
|
@click="handleSearch"
|
||||||
>
|
>
|
||||||
<i v-if="!loading" class="fas fa-search"></i>
|
<i v-if="!loading" class="fas fa-search"></i>
|
||||||
<span
|
<span
|
||||||
@ -50,7 +50,7 @@
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h6 class="mb-0 text-sm">{{ practitioner.full_name }}</h6>
|
<h6 class="mb-0 text-sm">{{ practitioner.full_name }}</h6>
|
||||||
<p class="text-xs text-muted mb-0">
|
<p class="text-xs text-muted mb-0">
|
||||||
{{ practitioner.job_title || 'Praticien' }}
|
{{ practitioner.job_title || "Praticien" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -70,7 +70,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, defineEmits } from 'vue';
|
import { ref, defineProps, defineEmits } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
loading: {
|
loading: {
|
||||||
@ -83,9 +83,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['search', 'select']);
|
const emit = defineEmits(["search", "select"]);
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref("");
|
||||||
|
|
||||||
const handleInput = () => {
|
const handleInput = () => {
|
||||||
// Optional: Implement debounce here if needed
|
// Optional: Implement debounce here if needed
|
||||||
@ -94,13 +94,13 @@ const handleInput = () => {
|
|||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
emit('search', searchQuery.value.trim());
|
emit("search", searchQuery.value.trim());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (practitioner) => {
|
const handleSelect = (practitioner) => {
|
||||||
emit('select', practitioner);
|
emit("select", practitioner);
|
||||||
searchQuery.value = ''; // Clear search after selection
|
searchQuery.value = ""; // Clear search after selection
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
<div class="d-lg-flex">
|
<div class="d-lg-flex">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-0">Nouveau Devis</h5>
|
<h5 class="mb-0">Nouveau Devis</h5>
|
||||||
<p class="text-sm mb-0">Créer un nouveau devis pour un client.</p>
|
<p class="text-sm mb-0">
|
||||||
|
Créer un nouveau devis pour un client.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-auto my-auto mt-lg-0 mt-4">
|
<div class="ms-auto my-auto mt-lg-0 mt-4">
|
||||||
<div class="ms-auto my-auto">
|
<div class="ms-auto my-auto">
|
||||||
@ -18,33 +20,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Client Selection -->
|
<!-- Client Selection -->
|
||||||
<div class="col-12 col-lg-4 mb-4">
|
<div class="col-12 col-lg-4 mb-4">
|
||||||
<slot name="client-selection"></slot>
|
<slot name="client-selection"></slot>
|
||||||
</div>
|
</div>
|
||||||
<!-- Quote Details -->
|
<!-- Quote Details -->
|
||||||
<div class="col-12 col-lg-8 mb-4">
|
<div class="col-12 col-lg-8 mb-4">
|
||||||
<slot name="quote-details"></slot>
|
<slot name="quote-details"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="horizontal dark my-4">
|
<hr class="horizontal dark my-4" />
|
||||||
|
|
||||||
<!-- Product Lines -->
|
<!-- Product Lines -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h6 class="mb-3">Produits & Services</h6>
|
<h6 class="mb-3">Produits & Services</h6>
|
||||||
<slot name="product-lines"></slot>
|
<slot name="product-lines"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="horizontal dark my-4">
|
<hr class="horizontal dark my-4" />
|
||||||
|
|
||||||
<!-- Totals -->
|
<!-- Totals -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-lg-4 ms-auto">
|
<div class="col-12 col-lg-4 ms-auto">
|
||||||
<slot name="totals"></slot>
|
<slot name="totals"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -53,5 +55,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup></script>
|
||||||
</script>
|
|
||||||
|
|||||||
@ -1,38 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-lg-8 mx-auto">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header pb-0 p-3">
|
<slot name="header"></slot>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<slot name="header"></slot>
|
<div class="card-body p-3 pt-0">
|
||||||
</div>
|
<hr class="horizontal dark mt-0 mb-4" />
|
||||||
|
|
||||||
|
<!-- Product Lines Section (replacing Gold Glasses) -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<slot name="lines"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="row">
|
<hr class="horizontal dark mt-4 mb-4" />
|
||||||
<div class="col-12 col-md-4 mb-4">
|
|
||||||
<slot name="info"></slot>
|
<div class="row">
|
||||||
</div>
|
<!-- Tracking/Timeline Section -->
|
||||||
<div class="col-12 col-md-8">
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
<h6 class="text-uppercase text-body text-xs font-weight-bolder mb-3">Détail des lignes</h6>
|
<slot name="timeline"></slot>
|
||||||
<slot name="lines"></slot>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<!-- Billing Info Section -->
|
||||||
<div class="col-md-6 ms-auto">
|
<div class="col-lg-5 col-md-6 col-12">
|
||||||
<slot name="totals"></slot>
|
<slot name="billing"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Summary Section -->
|
||||||
</div>
|
<div class="col-lg-3 col-12 ms-auto">
|
||||||
</div>
|
<slot name="summary"></slot>
|
||||||
<div class="card-footer p-3">
|
</div>
|
||||||
<slot name="actions"></slot>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer p-3">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup></script>
|
||||||
</script>
|
|
||||||
|
|||||||
@ -185,10 +185,10 @@
|
|||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<input
|
<input
|
||||||
|
v-model="userMessage"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Tapez votre message..."
|
placeholder="Tapez votre message..."
|
||||||
class="message-input"
|
class="message-input"
|
||||||
v-model="userMessage"
|
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<button class="send-btn" @click="sendMessage">
|
<button class="send-btn" @click="sendMessage">
|
||||||
|
|||||||
@ -170,6 +170,12 @@ export default {
|
|||||||
miniIcon: "C",
|
miniIcon: "C",
|
||||||
text: "Clients",
|
text: "Clients",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "clients-groups",
|
||||||
|
route: { name: "ClientGroups" },
|
||||||
|
miniIcon: "G",
|
||||||
|
text: "Groupes",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "clients-locations",
|
id: "clients-locations",
|
||||||
route: { name: "Localisation clients" },
|
route: { name: "Localisation clients" },
|
||||||
|
|||||||
@ -497,6 +497,27 @@ const routes = [
|
|||||||
name: "Nouveau Devis",
|
name: "Nouveau Devis",
|
||||||
component: () => import("@/views/pages/Ventes/NewQuote.vue"),
|
component: () => import("@/views/pages/Ventes/NewQuote.vue"),
|
||||||
},
|
},
|
||||||
|
// Client Groups
|
||||||
|
{
|
||||||
|
path: "/clients/groups",
|
||||||
|
name: "ClientGroups",
|
||||||
|
component: () => import("@/views/pages/Clients/ClientGroups.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/clients/groups/new",
|
||||||
|
name: "NewClientGroup",
|
||||||
|
component: () => import("@/views/pages/Clients/NewClientGroup.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/clients/groups/:id",
|
||||||
|
name: "ClientGroupDetail",
|
||||||
|
component: () => import("@/views/pages/Clients/ClientGroupDetail.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/clients/groups/:id/edit",
|
||||||
|
name: "EditClientGroup",
|
||||||
|
component: () => import("@/views/pages/Clients/NewClientGroup.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/ventes/statistiques",
|
path: "/ventes/statistiques",
|
||||||
name: "Statistiques ventes",
|
name: "Statistiques ventes",
|
||||||
@ -629,8 +650,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/parametrage/categories-produits",
|
path: "/parametrage/categories-produits",
|
||||||
name: "Product Categories",
|
name: "Product Categories",
|
||||||
component: () =>
|
component: () => import("@/views/pages/Parametrage/ProductCategories.vue"),
|
||||||
import("@/views/pages/Parametrage/ProductCategories.vue"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
107
thanasoft-front/src/services/clientGroup.ts
Normal file
107
thanasoft-front/src/services/clientGroup.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
export interface ClientGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientGroupListResponse {
|
||||||
|
data: ClientGroup[];
|
||||||
|
meta?: {
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientGroupResponse {
|
||||||
|
data: ClientGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateClientGroupPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateClientGroupPayload extends Partial<CreateClientGroupPayload> {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientGroupService = {
|
||||||
|
/**
|
||||||
|
* Get all client groups
|
||||||
|
*/
|
||||||
|
async getAllClientGroups(params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<ClientGroupListResponse> {
|
||||||
|
const response = await request<ClientGroupListResponse>({
|
||||||
|
url: "/api/client-groups",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific client group by ID
|
||||||
|
*/
|
||||||
|
async getClientGroup(id: number): Promise<ClientGroupResponse> {
|
||||||
|
const response = await request<ClientGroupResponse>({
|
||||||
|
url: `/api/client-groups/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new client group
|
||||||
|
*/
|
||||||
|
async createClientGroup(payload: CreateClientGroupPayload): Promise<ClientGroupResponse> {
|
||||||
|
const response = await request<ClientGroupResponse>({
|
||||||
|
url: "/api/client-groups",
|
||||||
|
method: "post",
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing client group
|
||||||
|
*/
|
||||||
|
async updateClientGroup(payload: UpdateClientGroupPayload): Promise<ClientGroupResponse> {
|
||||||
|
const { id, ...updateData } = payload;
|
||||||
|
|
||||||
|
const response = await request<ClientGroupResponse>({
|
||||||
|
url: `/api/client-groups/${id}`,
|
||||||
|
method: "put",
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a client group
|
||||||
|
*/
|
||||||
|
async deleteClientGroup(
|
||||||
|
id: number
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await request<{ success: boolean; message: string }>({
|
||||||
|
url: `/api/client-groups/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientGroupService;
|
||||||
@ -6,7 +6,7 @@ export interface Quote {
|
|||||||
client_id: number;
|
client_id: number;
|
||||||
group_id: number | null;
|
group_id: number | null;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
|
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
||||||
quote_date: string;
|
quote_date: string;
|
||||||
valid_until: string | null;
|
valid_until: string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
@ -52,7 +52,7 @@ export interface QuoteLine {
|
|||||||
export interface CreateQuotePayload {
|
export interface CreateQuotePayload {
|
||||||
client_id: number;
|
client_id: number;
|
||||||
group_id?: number | null;
|
group_id?: number | null;
|
||||||
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
|
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
||||||
quote_date: string;
|
quote_date: string;
|
||||||
valid_until?: string | null;
|
valid_until?: string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|||||||
221
thanasoft-front/src/stores/clientGroupStore.ts
Normal file
221
thanasoft-front/src/stores/clientGroupStore.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import ClientGroupService, {
|
||||||
|
ClientGroup,
|
||||||
|
CreateClientGroupPayload,
|
||||||
|
UpdateClientGroupPayload,
|
||||||
|
} from "@/services/clientGroup";
|
||||||
|
|
||||||
|
export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||||
|
// State
|
||||||
|
const clientGroups = ref<ClientGroup[]>([]);
|
||||||
|
const currentClientGroup = ref<ClientGroup | 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 allClientGroups = computed(() => clientGroups.value);
|
||||||
|
const isLoading = computed(() => loading.value);
|
||||||
|
const hasError = computed(() => error.value !== null);
|
||||||
|
const getError = computed(() => error.value);
|
||||||
|
const getClientGroupById = computed(() => (id: number) =>
|
||||||
|
clientGroups.value.find((group) => group.id === id)
|
||||||
|
);
|
||||||
|
const getPagination = computed(() => pagination.value);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setLoading = (isLoading: boolean) => {
|
||||||
|
loading.value = isLoading;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (err: string | null) => {
|
||||||
|
error.value = err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setClientGroups = (newClientGroups: ClientGroup[]) => {
|
||||||
|
clientGroups.value = newClientGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentClientGroup = (group: ClientGroup | null) => {
|
||||||
|
currentClientGroup.value = group;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 client groups with optional pagination and filters
|
||||||
|
*/
|
||||||
|
const fetchClientGroups = async (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientGroupService.getAllClientGroups(params);
|
||||||
|
setClientGroups(response.data);
|
||||||
|
if (response.meta) {
|
||||||
|
setPagination(response.meta);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch client groups";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single client group by ID
|
||||||
|
*/
|
||||||
|
const fetchClientGroup = async (id: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientGroupService.getClientGroup(id);
|
||||||
|
setCurrentClientGroup(response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch client group";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new client group
|
||||||
|
*/
|
||||||
|
const createClientGroup = async (payload: CreateClientGroupPayload) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientGroupService.createClientGroup(payload);
|
||||||
|
// Add the new group to the list
|
||||||
|
clientGroups.value.push(response.data);
|
||||||
|
setCurrentClientGroup(response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to create client group";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing client group
|
||||||
|
*/
|
||||||
|
const updateClientGroup = async (payload: UpdateClientGroupPayload) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientGroupService.updateClientGroup(payload);
|
||||||
|
const updatedGroup = response.data;
|
||||||
|
|
||||||
|
// Update in the groups list
|
||||||
|
const index = clientGroups.value.findIndex(
|
||||||
|
(group) => group.id === updatedGroup.id
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
clientGroups.value[index] = updatedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current group if it's the one being edited
|
||||||
|
if (currentClientGroup.value && currentClientGroup.value.id === updatedGroup.id) {
|
||||||
|
setCurrentClientGroup(updatedGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedGroup;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to update client group";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a client group
|
||||||
|
*/
|
||||||
|
const deleteClientGroup = async (id: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientGroupService.deleteClientGroup(id);
|
||||||
|
|
||||||
|
// Remove from the groups list
|
||||||
|
clientGroups.value = clientGroups.value.filter((group) => group.id !== id);
|
||||||
|
|
||||||
|
// Clear current group if it's the one being deleted
|
||||||
|
if (currentClientGroup.value && currentClientGroup.value.id === id) {
|
||||||
|
setCurrentClientGroup(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to delete client group";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
clientGroups,
|
||||||
|
currentClientGroup,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pagination,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
allClientGroups,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
getError,
|
||||||
|
getClientGroupById,
|
||||||
|
getPagination,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchClientGroups,
|
||||||
|
fetchClientGroup,
|
||||||
|
createClientGroup,
|
||||||
|
updateClientGroup,
|
||||||
|
deleteClientGroup,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -43,7 +43,9 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(params);
|
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(
|
||||||
|
params
|
||||||
|
);
|
||||||
this.categories = response.data;
|
this.categories = response.data;
|
||||||
if (response.pagination) {
|
if (response.pagination) {
|
||||||
this.meta = {
|
this.meta = {
|
||||||
@ -57,7 +59,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.error = error.message || "Erreur lors du chargement des catégories";
|
this.error =
|
||||||
|
error.message || "Erreur lors du chargement des catégories";
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@ -78,7 +81,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
this.currentCategory = response.data;
|
this.currentCategory = response.data;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.error = error.message || "Erreur lors du chargement de la catégorie";
|
this.error =
|
||||||
|
error.message || "Erreur lors du chargement de la catégorie";
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@ -89,12 +93,15 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.createProductCategory(data);
|
const response = await productCategoryService.createProductCategory(
|
||||||
|
data
|
||||||
|
);
|
||||||
this.categories.unshift(response.data);
|
this.categories.unshift(response.data);
|
||||||
this.meta.total++;
|
this.meta.total++;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.error = error.message || "Erreur lors de la création de la catégorie";
|
this.error =
|
||||||
|
error.message || "Erreur lors de la création de la catégorie";
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@ -105,7 +112,10 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.updateProductCategory(id, data);
|
const response = await productCategoryService.updateProductCategory(
|
||||||
|
id,
|
||||||
|
data
|
||||||
|
);
|
||||||
const index = this.categories.findIndex((c) => c.id === id);
|
const index = this.categories.findIndex((c) => c.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.categories[index] = response.data;
|
this.categories[index] = response.data;
|
||||||
@ -115,7 +125,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.error = error.message || "Erreur lors de la mise à jour de la catégorie";
|
this.error =
|
||||||
|
error.message || "Erreur lors de la mise à jour de la catégorie";
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@ -134,7 +145,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.error = error.message || "Erreur lors de la suppression de la catégorie";
|
this.error =
|
||||||
|
error.message || "Erreur lors de la suppression de la catégorie";
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<client-group-detail-presentation :group-id="groupId" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import ClientGroupDetailPresentation from "@/components/Organism/ClientGroup/ClientGroupDetailPresentation.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const groupId = computed(() => route.params.id);
|
||||||
|
</script>
|
||||||
7
thanasoft-front/src/views/pages/Clients/ClientGroups.vue
Normal file
7
thanasoft-front/src/views/pages/Clients/ClientGroups.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<client-group-list-presentation />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ClientGroupListPresentation from "@/components/Organism/ClientGroup/ClientGroupListPresentation.vue";
|
||||||
|
</script>
|
||||||
12
thanasoft-front/src/views/pages/Clients/NewClientGroup.vue
Normal file
12
thanasoft-front/src/views/pages/Clients/NewClientGroup.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<client-group-form-presentation :group-id="groupId" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import ClientGroupFormPresentation from "@/components/Organism/ClientGroup/ClientGroupFormPresentation.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const groupId = computed(() => route.params.id || null);
|
||||||
|
</script>
|
||||||
@ -25,14 +25,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body px-0 pb-0">
|
<div class="card-body px-0 pb-0">
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
<!-- You could add search here -->
|
<!-- You could add search here -->
|
||||||
</div>
|
</div>
|
||||||
<ProductCategoryList
|
<ProductCategoryList
|
||||||
:categories="store.categories"
|
:categories="store.categories"
|
||||||
@edit="openEditModal"
|
@edit="openEditModal"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Pagination if needed -->
|
<!-- Pagination if needed -->
|
||||||
<!-- <div class="px-4 py-3 border-top d-flex justify-content-end">
|
<!-- <div class="px-4 py-3 border-top d-flex justify-content-end">
|
||||||
Pagination component
|
Pagination component
|
||||||
@ -101,16 +101,16 @@ const handleCloseModal = () => {
|
|||||||
const handleSave = async (formData) => {
|
const handleSave = async (formData) => {
|
||||||
try {
|
try {
|
||||||
if (formData.id) {
|
if (formData.id) {
|
||||||
await store.updateCategory(formData.id, formData);
|
await store.updateCategory(formData.id, formData);
|
||||||
Swal.fire("Succès", "Catégorie mise à jour avec succès", "success");
|
Swal.fire("Succès", "Catégorie mise à jour avec succès", "success");
|
||||||
} else {
|
} else {
|
||||||
await store.createCategory(formData);
|
await store.createCategory(formData);
|
||||||
Swal.fire("Succès", "Catégorie créée avec succès", "success");
|
Swal.fire("Succès", "Catégorie créée avec succès", "success");
|
||||||
// Update all categories list for dropdowns if new one created
|
// Update all categories list for dropdowns if new one created
|
||||||
const response = await store.fetchAllCategories();
|
const response = await store.fetchAllCategories();
|
||||||
allCategories.value = response.data || [];
|
allCategories.value = response.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modalComponent.value) {
|
if (modalComponent.value) {
|
||||||
modalComponent.value.hide();
|
modalComponent.value.hide();
|
||||||
}
|
}
|
||||||
@ -135,9 +135,9 @@ const handleDelete = async (id) => {
|
|||||||
try {
|
try {
|
||||||
await store.deleteCategory(id);
|
await store.deleteCategory(id);
|
||||||
Swal.fire("Supprimé !", "La catégorie a été supprimée.", "success");
|
Swal.fire("Supprimé !", "La catégorie a été supprimée.", "success");
|
||||||
// Update all categories list for dropdowns
|
// Update all categories list for dropdowns
|
||||||
const response = await store.fetchAllCategories();
|
const response = await store.fetchAllCategories();
|
||||||
allCategories.value = response.data || [];
|
allCategories.value = response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Swal.fire("Erreur", error.message || "Impossible de supprimer", "error");
|
Swal.fire("Erreur", error.message || "Impossible de supprimer", "error");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="text-center py-4">
|
<div v-else class="text-center py-4">
|
||||||
<p>Catégorie non trouvée</p>
|
<p>Catégorie non trouvée</p>
|
||||||
<button @click="goBack" class="btn btn-primary">Retour</button>
|
<button class="btn btn-primary" @click="goBack">Retour</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -3,5 +3,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import QuoteListPresentation from '@/components/Organism/Quote/QuoteListPresentation.vue';
|
import QuoteListPresentation from "@/components/Organism/Quote/QuoteListPresentation.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,5 +3,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import QuoteCreationPresentation from '@/components/Organism/Quote/QuoteCreationPresentation.vue';
|
import QuoteCreationPresentation from "@/components/Organism/Quote/QuoteCreationPresentation.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from "vue";
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from "vue-router";
|
||||||
import QuoteDetailPresentation from '@/components/Organism/Quote/QuoteDetailPresentation.vue';
|
import QuoteDetailPresentation from "@/components/Organism/Quote/QuoteDetailPresentation.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const quoteId = computed(() => route.params.id);
|
const quoteId = computed(() => route.params.id);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user