feat: Introduce client group management and enhance quote creation and detail views.

This commit is contained in:
kevin 2026-01-07 18:17:11 +03:00
parent d911435b5c
commit 50f79a8040
76 changed files with 2544 additions and 931 deletions

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({
@ -168,7 +176,7 @@ const isEditing = computed(() => !!props.category);
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 = () => {

View File

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

View File

@ -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 => { form.value.lines.forEach((line) => {
const lineHt = line.quantity * line.unit_price; const lineHt = line.quantity * line.unit_price;
const lineTva = lineHt * (line.tva / 100); const lineTva = lineHt * (line.tva / 100);
ht += lineHt; ht += lineHt;
tva += lineTva; tva += lineTva;
}); });
return { return {
ht, ht,
tva, tva,
ttc: 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (searchTimeout) clearTimeout(searchTimeout);
if (query.length < 2) { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
</div>
<div class="card-body p-3">
<div class="row">
<div class="col-12 col-md-4 mb-4">
<slot name="info"></slot>
</div>
<div class="col-12 col-md-8">
<h6 class="text-uppercase text-body text-xs font-weight-bolder mb-3">Détail des lignes</h6>
<slot name="lines"></slot>
<div class="row mt-4"> <div class="card-body p-3 pt-0">
<div class="col-md-6 ms-auto"> <hr class="horizontal dark mt-0 mb-4" />
<slot name="totals"></slot>
</div> <!-- Product Lines Section (replacing Gold Glasses) -->
</div> <div class="row">
</div> <div class="col-12">
</div> <slot name="lines"></slot>
</div>
</div> </div>
<div class="card-footer p-3">
<slot name="actions"></slot> <hr class="horizontal dark mt-4 mb-4" />
<div class="row">
<!-- Tracking/Timeline Section -->
<div class="col-lg-3 col-md-6 col-12">
<slot name="timeline"></slot>
</div>
<!-- Billing Info Section -->
<div class="col-lg-5 col-md-6 col-12">
<slot name="billing"></slot>
</div>
<!-- Summary Section -->
<div class="col-lg-3 col-12 ms-auto">
<slot name="summary"></slot>
</div>
</div> </div>
</div>
<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>

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
<template>
<client-group-list-presentation />
</template>
<script setup>
import ClientGroupListPresentation from "@/components/Organism/ClientGroup/ClientGroupListPresentation.vue";
</script>

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

View File

@ -25,7 +25,7 @@
</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"
@ -101,14 +101,14 @@ 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) {
@ -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");
} }

View File

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

View File

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

View File

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

View File

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