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
{
$quoteId = $this->route('quote');
return [
'client_id' => 'sometimes|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id',
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $this->quote->id,
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $quoteId,
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'sometimes|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,
'client' => $this->whenLoaded('client'),
'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);
}
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);
}
public function create(array $data): Quote
{
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;
} catch (\Exception $e) {
// Log the error
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
'exception' => $e,
'data' => $data,
]);
// Re-throw to trigger rollback
throw $e;
}
});
}
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) {
let value = interventionForm.value[key];
// Fix date format for scheduled_at
if (key === 'scheduled_at' && value) {
value = value.replace('T', ' ');
if (key === "scheduled_at" && value) {
value = value.replace("T", " ");
if (value.length === 16) {
value += ':00';
value += ":00";
}
}
formData.append(`intervention[${key}]`, value);

View File

@ -135,7 +135,10 @@
placeholder="Code postal"
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") }}
</div>
</div>
@ -176,8 +179,8 @@
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
@click="$emit('next')"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>

View File

@ -97,8 +97,8 @@
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
@click="$emit('next')"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>

View File

@ -9,16 +9,13 @@
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('death_certificate') }"
@change="handleFileUpload($event, 'death_certificate')"
accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileUpload($event, 'death_certificate')"
/>
<small class="text-muted">
Formats acceptés: PDF, JPG, PNG (Max 5MB)
</small>
<div
v-if="getFieldError('death_certificate')"
class="invalid-feedback"
>
<div v-if="getFieldError('death_certificate')" class="invalid-feedback">
{{ getFieldError("death_certificate") }}
</div>
</div>
@ -28,8 +25,8 @@
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('care_authorization') }"
@change="handleFileUpload($event, 'care_authorization')"
accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileUpload($event, 'care_authorization')"
/>
<div
v-if="getFieldError('care_authorization')"
@ -44,13 +41,10 @@
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('identity_document') }"
@change="handleFileUpload($event, 'identity_document')"
accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileUpload($event, 'identity_document')"
/>
<div
v-if="getFieldError('identity_document')"
class="invalid-feedback"
>
<div v-if="getFieldError('identity_document')" class="invalid-feedback">
{{ getFieldError("identity_document") }}
</div>
</div>
@ -60,14 +54,11 @@
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('other_documents') }"
@change="handleFileUpload($event, 'other_documents')"
accept=".pdf,.jpg,.jpeg,.png"
multiple
@change="handleFileUpload($event, 'other_documents')"
/>
<div
v-if="getFieldError('other_documents')"
class="invalid-feedback"
>
<div v-if="getFieldError('other_documents')" class="invalid-feedback">
{{ getFieldError("other_documents") }}
</div>
</div>
@ -102,8 +93,8 @@
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
@click="$emit('next')"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>

View File

@ -58,10 +58,7 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Thanatopracteur</label>
<select
v-model="formData.assigned_practitioner_id"
class="form-select"
>
<select v-model="formData.assigned_practitioner_id" class="form-select">
<option value="">Sélectionner un thanatopracteur</option>
<option
v-for="practitioner in practitioners"
@ -158,8 +155,8 @@ const props = defineProps({
},
});
defineEmits(["prev"]); // Submit is handled by form @submit in parent or here?
// The parent form tag wraps all panels.
defineEmits(["prev"]); // Submit is handled by form @submit in parent or here?
// The parent form tag wraps all panels.
// If this button is type="submit", it submits the parent form.
// But wait, the parent sends 'handleSubmit' on form submit.
// So this button just triggers the submit event of the form.

View File

@ -99,8 +99,8 @@
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
@click="$emit('next')"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>

View File

@ -7,29 +7,37 @@
<label class="form-label">Type de produit *</label>
<div class="d-flex flex-column gap-2">
<div v-if="loading" class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else-if="interventionProducts.length === 0" class="text-center py-3 text-muted">
Aucun produit d'intervention trouvé.
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div
v-else-if="interventionProducts.length === 0"
class="text-center py-3 text-muted"
>
Aucun produit d'intervention trouvé.
</div>
<div
v-else
v-for="product in interventionProducts"
v-else
:key="product.id"
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
:id="'product-' + product.id"
v-model="formData.product_id"
class="form-check-input"
type="radio"
name="productSelection"
:id="'product-' + 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 }}
<div class="text-muted fw-normal small">
{{ product.description || product.reference }}
@ -38,7 +46,10 @@
</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") }}
</div>
</div>
@ -52,8 +63,8 @@
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
@click="$emit('next')"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
@ -105,7 +116,10 @@ onMounted(async () => {
loading.value = true;
try {
// 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;
} catch (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 -->
<div v-else-if="error" class="alert alert-danger text-center py-4">
<p>{{ error }}</p>
<button @click="$emit('retry')" class="btn btn-outline-danger">
<button class="btn btn-outline-danger" @click="$emit('retry')">
Réessayer
</button>
</div>

View File

@ -12,15 +12,15 @@
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<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>
Ajouter un praticien
</h5>
<button
type="button"
class="btn-close"
@click="handleClose"
aria-label="Close"
@click="handleClose"
></button>
</div>
<div class="modal-body">
@ -54,8 +54,8 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import PractitionerSearchInput from '@/components/molecules/thanatopractitioner/PractitionerSearchInput.vue';
import { defineProps, defineEmits } from "vue";
import PractitionerSearchInput from "@/components/molecules/thanatopractitioner/PractitionerSearchInput.vue";
const props = defineProps({
show: {
@ -72,18 +72,18 @@ const props = defineProps({
},
});
const emit = defineEmits(['close', 'search', 'select']);
const emit = defineEmits(["close", "search", "select"]);
const handleClose = () => {
emit('close');
emit("close");
};
const handleSearch = (query) => {
emit('search', query);
emit("search", query);
};
const handleSelect = (practitioner) => {
emit('select', practitioner);
emit("select", practitioner);
};
</script>

View File

@ -165,8 +165,8 @@
<button
type="button"
class="btn btn-sm btn-outline-info ms-auto"
@click="$emit('assign-practitioner')"
:disabled="loading"
@click="$emit('assign-practitioner')"
>
Ajouter un thanatopracteur
</button>
@ -212,14 +212,14 @@
<td class="text-end pe-3">
<button
class="btn btn-sm btn-outline-danger"
title="Désassigner le praticien"
:disabled="loading"
@click="
$emit('unassign-practitioner', {
practitionerId: practitioner.id,
practitionerName: practitioner.employee_name,
})
"
title="Désassigner le praticien"
:disabled="loading"
>
<i class="fas fa-times"></i>
<span class="d-none d-sm-inline ms-1"

View File

@ -38,8 +38,8 @@
<div class="row">
<div class="col-md-6">
<SoftInput
label="Nom du lieu *"
v-model="newLocation.name"
label="Nom du lieu *"
:disabled="creating"
required
class="mb-3"
@ -47,8 +47,8 @@
</div>
<div class="col-md-6">
<SoftInput
label="Adresse"
v-model="newLocation.address"
label="Adresse"
:disabled="creating"
class="mb-3"
/>
@ -58,17 +58,17 @@
<div class="row">
<div class="col-md-6">
<SoftInput
label="Téléphone"
v-model="newLocation.phone"
label="Téléphone"
:disabled="creating"
class="mb-3"
/>
</div>
<div class="col-md-6">
<SoftInput
v-model="newLocation.email"
label="Email"
type="email"
v-model="newLocation.email"
:disabled="creating"
class="mb-3"
/>
@ -77,10 +77,10 @@
<div class="mb-3">
<SoftInput
v-model="newLocation.description"
label="Description"
type="textarea"
rows="3"
v-model="newLocation.description"
:disabled="creating"
placeholder="Informations supplémentaires sur le lieu..."
/>
@ -108,16 +108,16 @@
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
@click="closeCreateModal"
:disabled="creating"
@click="closeCreateModal"
>
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-primary"
@click="submitLocation"
:disabled="creating || !newLocation.name.trim()"
@click="submitLocation"
>
<span
v-if="creating"

View File

@ -47,7 +47,10 @@
</td>
<td>
<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 }}
</p>
</td>
@ -59,15 +62,23 @@
<td class="align-middle text-center text-sm">
<span
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" }}
</span>
</td>
<td class="align-middle text-center text-sm">
<td class="align-middle text-center text-sm">
<span
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" }}
</span>

View File

@ -1,17 +1,17 @@
<template>
<div
class="modal fade"
id="productCategoryModal"
ref="modalRef"
class="modal fade"
tabindex="-1"
role="dialog"
aria-labelledby="productCategoryModalLabel"
aria-hidden="true"
ref="modalRef"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="productCategoryModalLabel">
<h5 id="productCategoryModalLabel" class="modal-title">
{{ isEditing ? "Modifier la catégorie" : "Nouvelle catégorie" }}
</h5>
<button
@ -28,10 +28,10 @@
<div class="mb-3">
<label for="categoryCode" class="form-label">Code</label>
<input
type="text"
class="form-control"
id="categoryCode"
v-model="form.code"
type="text"
class="form-control"
required
:disabled="isEditing"
placeholder="Ex: SOINS, URNE"
@ -40,10 +40,10 @@
<div class="mb-3">
<label for="categoryName" class="form-label">Nom</label>
<input
type="text"
class="form-control"
id="categoryName"
v-model="form.name"
type="text"
class="form-control"
required
placeholder="Ex: Soins de conservation"
/>
@ -53,9 +53,9 @@
>Catégorie Parente</label
>
<select
class="form-select"
id="parentCategory"
v-model="form.parent_id"
class="form-select"
>
<option :value="null">Aucune (Racine)</option>
<option
@ -72,18 +72,18 @@
>Description</label
>
<textarea
class="form-control"
id="categoryDescription"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
id="interventionSwitch"
v-model="form.intervention"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="interventionSwitch"
>Lié à une intervention ?</label
@ -91,10 +91,10 @@
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
id="activeSwitch"
v-model="form.active"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="activeSwitch">Actif</label>
</div>
@ -111,8 +111,8 @@
<button
type="button"
class="btn btn-primary"
@click="handleSubmit"
:disabled="loading"
@click="handleSubmit"
>
{{
loading
@ -129,7 +129,15 @@
</template>
<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";
const props = defineProps({
@ -167,8 +175,8 @@ const isEditing = computed(() => !!props.category);
// Filter out self and children from parent selection to avoid loops
const availableParents = computed(() => {
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 = () => {

View File

@ -137,9 +137,9 @@
</span>
</div>
<button
@click="handleViewSupplier(productData.fournisseur)"
class="supplier-action-btn"
title="Voir le fournisseur"
@click="handleViewSupplier(productData.fournisseur)"
>
<i class="fas fa-external-link-alt"></i>
</button>

View File

@ -1,93 +1,88 @@
<template>
<create-quote-template>
<template #actions>
<soft-button
color="secondary"
variant="outline"
class="me-2"
@click="cancel"
>
Annuler
</soft-button>
<soft-button
color="primary"
variant="gradient"
@click="saveQuote"
:disabled="loading"
>
{{ loading ? 'Enregistrement...' : 'Enregistrer' }}
</soft-button>
<soft-button
color="secondary"
variant="outline"
class="me-2"
@click="cancel"
>
Annuler
</soft-button>
<soft-button
color="primary"
variant="gradient"
:disabled="loading"
@click="saveQuote"
>
{{ loading ? "Enregistrement..." : "Enregistrer" }}
</soft-button>
</template>
<template #client-selection>
<label>Client</label>
<select class="form-select" v-model="form.client_id">
<option value="" disabled selected>Sélectionner un client</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
<!-- Add client search/autocomplete if list is long -->
<label>Client</label>
<select v-model="form.client_id" class="form-select">
<option value="" disabled selected>Sélectionner un client</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
<!-- Add client search/autocomplete if list is long -->
</template>
<template #quote-details>
<div class="row">
<div class="col-md-6">
<label>Date du devis</label>
<input type="date" class="form-control" v-model="form.quote_date">
</div>
<div class="col-md-6">
<label>Validité (Date)</label>
<input type="date" class="form-control" v-model="form.valid_until">
</div>
<div class="row">
<div class="col-md-6">
<label>Date du devis</label>
<input v-model="form.quote_date" type="date" class="form-control" />
</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 #product-lines>
<div v-for="(line, index) in form.lines" :key="index">
<product-line-item
v-model="form.lines[index]"
@remove="removeLine(index)"
/>
</div>
<soft-button
color="info"
variant="text"
size="sm"
@click="addLine"
>
<i class="fas fa-plus me-1"></i> Ajouter une ligne
</soft-button>
<div v-for="(line, index) in form.lines" :key="index">
<product-line-item
v-model="form.lines[index]"
@remove="removeLine(index)"
/>
</div>
<soft-button color="info" variant="text" size="sm" @click="addLine">
<i class="fas fa-plus me-1"></i> Ajouter une ligne
</soft-button>
</template>
<template #totals>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between">
<span>Total HT</span>
<strong>{{ formatCurrency(totals.ht) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>TVA</span>
<strong>{{ formatCurrency(totals.tva) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between bg-gray-100">
<span>Total TTC</span>
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
</li>
</ul>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between">
<span>Total HT</span>
<strong>{{ formatCurrency(totals.ht) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>TVA</span>
<strong>{{ formatCurrency(totals.tva) }}</strong>
</li>
<li class="list-group-item d-flex justify-content-between bg-gray-100">
<span>Total TTC</span>
<strong class="text-primary">{{ formatCurrency(totals.ttc) }}</strong>
</li>
</ul>
</template>
</create-quote-template>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import CreateQuoteTemplate from '@/components/templates/Quote/CreateQuoteTemplate.vue';
import ProductLineItem from '@/components/molecules/Quote/ProductLineItem.vue';
import SoftButton from '@/components/SoftButton.vue';
import { useQuoteStore } from '@/stores/quoteStore';
import { useClientStore } from '@/stores/clientStore';
import { storeToRefs } from 'pinia';
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import CreateQuoteTemplate from "@/components/templates/Quote/CreateQuoteTemplate.vue";
import ProductLineItem from "@/components/molecules/Quote/ProductLineItem.vue";
import SoftButton from "@/components/SoftButton.vue";
import { useQuoteStore } from "@/stores/quoteStore";
import { useClientStore } from "@/stores/clientStore";
import { storeToRefs } from "pinia";
const router = useRouter();
const quoteStore = useQuoteStore();
@ -97,87 +92,107 @@ const { clients } = storeToRefs(clientStore);
const loading = ref(false);
const form = ref({
client_id: '',
quote_date: new Date().toISOString().split('T')[0],
valid_until: '',
status: 'brouillon',
currency: 'EUR',
lines: [
{ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 }
]
client_id: "",
quote_date: new Date().toISOString().split("T")[0],
valid_until: "",
status: "brouillon",
currency: "EUR",
lines: [
{
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
},
],
});
const totals = computed(() => {
let ht = 0;
let tva = 0;
form.value.lines.forEach(line => {
const lineHt = line.quantity * line.unit_price;
const lineTva = lineHt * (line.tva / 100);
ht += lineHt;
tva += lineTva;
});
let ht = 0;
let tva = 0;
return {
ht,
tva,
ttc: ht + tva
};
form.value.lines.forEach((line) => {
const lineHt = line.quantity * line.unit_price;
const lineTva = lineHt * (line.tva / 100);
ht += lineHt;
tva += lineTva;
});
return {
ht,
tva,
ttc: ht + tva,
};
});
const addLine = () => {
form.value.lines.push({ product_id: null, product_name: '', quantity: 1, unit_price: 0, tva: 20, discount_pct: 0 });
form.value.lines.push({
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
discount_pct: 0,
});
};
const removeLine = (index) => {
form.value.lines.splice(index, 1);
form.value.lines.splice(index, 1);
};
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 () => {
if (!form.value.client_id) {
alert('Veuillez sélectionner un client');
return;
}
if (!form.value.client_id) {
alert("Veuillez sélectionner un client");
return;
}
loading.value = true;
try {
await quoteStore.createQuote({
client_id: form.value.client_id,
status: form.value.status,
quote_date: form.value.quote_date,
valid_until: form.value.valid_until,
currency: form.value.currency,
total_ht: totals.value.ht,
total_tva: totals.value.tva,
total_ttc: totals.value.ttc,
// Assuming backend handles lines separately or we need to pass them
// If backend expects lines in payload, we need to add them to interface and store
lines: form.value.lines.map(line => ({
...line,
discount_pct: line.discount_pct || 0,
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
total_ht: (line.quantity * line.unit_price) * (1 - (line.discount_pct || 0) / 100),
description: line.product_name || 'Produit sans nom' // Ensure description is set
}))
});
router.push('/ventes/devis');
} catch (error) {
console.error(error);
alert('Erreur lors de la création du devis');
} finally {
loading.value = false;
}
loading.value = true;
try {
await quoteStore.createQuote({
client_id: form.value.client_id,
status: form.value.status,
quote_date: form.value.quote_date,
valid_until: form.value.valid_until,
currency: form.value.currency,
total_ht: totals.value.ht,
total_tva: totals.value.tva,
total_ttc: totals.value.ttc,
// Assuming backend handles lines separately or we need to pass them
// If backend expects lines in payload, we need to add them to interface and store
lines: form.value.lines.map((line) => ({
...line,
discount_pct: line.discount_pct || 0,
// Calculate total_ht for the line: qty * unit_price * (1 - discount/100)
total_ht:
line.quantity *
line.unit_price *
(1 - (line.discount_pct || 0) / 100),
description: line.product_name || "Produit sans nom", // Ensure description is set
})),
});
router.push("/ventes/devis");
} catch (error) {
console.error(error);
alert("Erreur lors de la création du devis");
} finally {
loading.value = false;
}
};
const cancel = () => {
router.back();
router.back();
};
onMounted(() => {
clientStore.fetchClients();
clientStore.fetchClients();
});
</script>

View File

@ -1,107 +1,183 @@
<template>
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="error" class="text-center py-5 text-danger">
{{ error }}
{{ error }}
</div>
<quote-detail-template v-else-if="quote">
<template #header>
<div>
<h5 class="mb-0">Détails du Devis {{ quote.reference }}</h5>
<p class="text-sm mb-0">Créé le {{ formatDate(quote.created_at) }}</p>
</div>
<div>
<soft-button color="secondary" variant="outline" size="sm" class="me-2" @click="goBack">
<i class="fas fa-arrow-left me-1"></i> Retour
</soft-button>
<soft-button color="primary" variant="gradient" size="sm">
<i class="fas fa-edit me-1"></i> Modifier
</soft-button>
</div>
</template>
<template #header>
<quote-header
:reference="quote.reference"
:date="quote.quote_date"
:code="quote.reference"
/>
</template>
<template #info>
<quote-info-card
:reference="quote.reference"
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
:quote-date="quote.quote_date"
:valid-until="quote.valid_until"
:status="quote.status"
/>
</template>
<template #lines>
<quote-lines-table :lines="quote.lines" />
</template>
<template #lines>
<quote-lines-table :lines="quote.lines" />
</template>
<template #timeline>
<quote-timeline :history="quote.history" />
</template>
<template #totals>
<quote-totals-card
:totals="{
ht: quote.total_ht,
tva: quote.total_tva,
ttc: quote.total_ttc
}"
/>
</template>
<template #billing>
<quote-billing-info
:client-name="quote.client ? quote.client.name : 'Client inconnu'"
:client-email="quote.client ? quote.client.email : ''"
:client-phone="quote.client ? quote.client.phone : ''"
/>
</template>
<template #actions>
<div class="d-flex justify-content-end">
<soft-button v-if="quote.status === 'brouillon'" color="success" variant="gradient" class="me-2">
Valider le devis
</soft-button>
<soft-button color="info" variant="outline">
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
</soft-button>
</div>
</template>
<template #summary>
<quote-summary
:ht="quote.total_ht"
:tva="quote.total_tva"
:ttc="quote.total_ttc"
/>
</template>
<template #actions>
<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>
</template>
<script setup>
import { ref, onMounted, defineProps } from 'vue';
import { useRouter } from 'vue-router';
import { useQuoteStore } from '@/stores/quoteStore';
import QuoteDetailTemplate from '@/components/templates/Quote/QuoteDetailTemplate.vue';
import QuoteInfoCard from '@/components/molecules/Quote/QuoteInfoCard.vue';
import QuoteLinesTable from '@/components/molecules/Quote/QuoteLinesTable.vue';
import QuoteTotalsCard from '@/components/molecules/Quote/QuoteTotalsCard.vue';
import SoftButton from '@/components/SoftButton.vue';
import { ref, onMounted, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useQuoteStore } from "@/stores/quoteStore";
import { useNotificationStore } from "@/stores/notification";
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
import QuoteHeader from "@/components/molecules/Quote/QuoteHeader.vue";
import QuoteTimeline from "@/components/molecules/Quote/QuoteTimeline.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({
quoteId: {
type: [String, Number],
required: true
}
quoteId: {
type: [String, Number],
required: true,
},
});
const router = useRouter();
const quoteStore = useQuoteStore();
const notificationStore = useNotificationStore();
const quote = ref(null);
const loading = ref(true);
const error = ref(null);
onMounted(async () => {
loading.value = true;
try {
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
quote.value = fetchedQuote;
} catch (e) {
error.value = "Impossible de charger le devis.";
console.error(e);
} finally {
loading.value = false;
}
loading.value = true;
try {
const fetchedQuote = await quoteStore.fetchQuote(props.quoteId);
quote.value = fetchedQuote;
} catch (e) {
error.value = "Impossible de charger le devis.";
console.error(e);
} finally {
loading.value = false;
}
});
const goBack = () => {
router.back();
router.back();
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fr-FR');
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR");
};
</script>
const availableStatuses = [
"brouillon",
"envoye",
"accepte",
"refuse",
"expire",
"annule",
];
const getStatusLabel = (status) => {
const labels = {
brouillon: "Brouillon",
envoye: "Envoyé",
accepte: "Accepté",
refuse: "Refusé",
expire: "Expiré",
annule: "Annulé",
};
return labels[status] || status;
};
/* eslint-disable require-atomic-updates */
const changeStatus = async (newStatus) => {
if (!quote.value?.id) return;
// Capture the current quote ID to prevent race conditions
const currentQuoteId = quote.value.id;
try {
loading.value = true;
const updated = await quoteStore.updateQuote({
id: currentQuoteId,
status: newStatus,
});
// Only update if we're still viewing the same quote
if (quote.value?.id === currentQuoteId) {
quote.value = updated;
// Show success notification
notificationStore.success(
'Statut mis à jour',
`Le devis est maintenant "${getStatusLabel(newStatus)}"`,
3000
);
}
} catch (e) {
console.error("Failed to update status", e);
notificationStore.error(
'Erreur',
'Impossible de mettre à jour le statut',
3000
);
} finally {
loading.value = false;
}
};
</script>

View File

@ -1,56 +1,51 @@
<template>
<list-quote-template>
<template #quote-new-action>
<add-button text="Ajouter" @click="openCreateModal"/>
</template>
<template #select-filter>
<filter-table />
</template>
<template #quote-table>
<quote-table
:data="quotes"
:loading="loading"
@view="handleView"
@edit="handleEdit"
@delete="handleDelete"
/>
</template>
</list-quote-template>
<div class="container-fluid py-4">
<quote-list-controls @create="openCreateModal" />
<div class="row">
<div class="col-12">
<quote-table
:data="quotes"
:loading="loading"
@view="handleView"
@delete="handleDelete"
/>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router'; // Import router
import ListQuoteTemplate from '@/components/templates/Quote/ListQuoteTemplate.vue';
import addButton from '@/components/molecules/new-button/addButton.vue';
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import { onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import QuoteListControls from "@/components/molecules/Quote/QuoteListControls.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 { quotes, loading } = storeToRefs(quoteStore);
const openCreateModal = () => {
router.push('/ventes/devis/new');
router.push("/ventes/devis/new");
};
const handleView = (id) => {
router.push(`/ventes/devis/${id}`);
console.log("handleView called with id:", id);
router.push(`/ventes/devis/${id}`);
};
const handleEdit = (id) => {
console.log("Edit quote", id);
console.log("Edit quote", id);
};
const handleDelete = async (id) => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce devis ?')) {
await quoteStore.deleteQuote(id);
}
if (confirm("Êtes-vous sûr de vouloir supprimer ce devis ?")) {
await quoteStore.deleteQuote(id);
}
};
onMounted(() => {
quoteStore.fetchQuotes();
quoteStore.fetchQuotes();
});
</script>
</script>

View File

@ -48,7 +48,7 @@
</div>
<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"
>Référence *</label
>
@ -102,7 +102,7 @@
</div>
<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>
<soft-input
id="fabricant"
@ -124,7 +124,7 @@
<!-- Stock Information -->
<div class="row">
<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"
>Stock Actuel *</label
>
@ -148,7 +148,7 @@
</div>
<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"
>Stock Minimum *</label
>
@ -171,7 +171,7 @@
</div>
</div>
<div class="col-md-4" v-if="!isIntervention">
<div v-if="!isIntervention" class="col-md-4">
<div class="form-group">
<label for="unite" class="form-label">Unité *</label>
<select
@ -232,7 +232,7 @@
</div>
<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"
>Date d'Expiration</label
>
@ -258,7 +258,7 @@
<!-- Lot Number -->
<div class="row">
<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"
>Numéro de Lot</label
>
@ -279,7 +279,7 @@
</div>
<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"
>Fournisseur</label
>
@ -309,7 +309,7 @@
</div>
<!-- Packaging Information -->
<div class="row" v-if="!isIntervention">
<div v-if="!isIntervention" class="row">
<div class="col-md-4">
<div class="form-group">
<label for="conditionnement_nom" class="form-label"
@ -383,7 +383,7 @@
</div>
<!-- URLs -->
<div class="row" v-if="!isIntervention">
<div v-if="!isIntervention" class="row">
<div class="col-md-6">
<div class="form-group">
<label for="photo_url" class="form-label"

View File

@ -28,8 +28,8 @@
<button
type="button"
class="mt-2 mb-0 btn bg-gradient-danger"
@click="deleteProduct"
:disabled="loading"
@click="deleteProduct"
>
<i class="fas fa-trash me-2"></i>
Supprimer
@ -46,8 +46,8 @@
<button
type="button"
class="mt-2 mb-0 btn bg-gradient-success"
@click="saveProduct"
:disabled="saving"
@click="saveProduct"
>
<i v-if="saving" class="fas fa-spinner fa-spin me-2"></i>
<i v-else class="fas fa-save me-2"></i>
@ -466,9 +466,9 @@
</span>
</div>
<button
@click="handleViewSupplier(productData.fournisseur)"
class="supplier-action-btn"
title="Voir le fournisseur"
@click="handleViewSupplier(productData.fournisseur)"
>
<i class="fas fa-external-link-alt"></i>
</button>

View File

@ -1,8 +1,8 @@
<template>
<button
class="btn bg-gradient-primary mb-0"
@click="$emit('click')"
type="button"
@click="$emit('click')"
>
<i class="fas fa-plus me-2"></i>
{{ text }}

View File

@ -19,16 +19,16 @@
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="$emit('edit', location)"
:disabled="disabled"
@click="$emit('edit', location)"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger ms-1"
@click="$emit('remove')"
:disabled="disabled"
@click="$emit('remove')"
>
<i class="fas fa-trash"></i>
</button>

View File

@ -1,13 +1,13 @@
<template>
<div class="location-search-input">
<SoftInput
v-model="searchQuery"
:label="label"
:placeholder="placeholder"
v-model="searchQuery"
@input="handleSearch"
@keydown="handleKeydown"
:disabled="disabled"
:class="{ 'is-invalid': hasError }"
@input="handleSearch"
@keydown="handleKeydown"
/>
<!-- Search Results Dropdown -->
@ -20,9 +20,9 @@
v-for="(location, index) in filteredLocations"
:key="location.id"
class="list-group-item list-group-item-action"
:class="{ active: highlightIndex === index }"
@click="selectLocation(location)"
@mouseenter="highlightIndex = index"
:class="{ active: highlightIndex === index }"
>
<div class="d-flex justify-content-between align-items-center">
<span>{{ location.name }}</span>

View File

@ -80,8 +80,8 @@
<button
type="button"
class="btn-close btn-close-sm ms-2"
@click="clearSelection"
aria-label="Clear selection"
@click="clearSelection"
></button>
</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-body">
<ul
class="nav nav-tabs card-header-tabs"
id="defuntTabs"
class="nav nav-tabs card-header-tabs"
role="tablist"
>
<li class="nav-item" role="presentation">
<button
class="nav-link"
:class="{ active: activeTab === 'details' }"
@click="activeTab = 'details'"
type="button"
@click="activeTab = 'details'"
>
<i class="fas fa-user me-2"></i>
Détails
@ -33,8 +33,8 @@
<button
class="nav-link"
:class="{ active: activeTab === 'documents' }"
@click="activeTab = 'documents'"
type="button"
@click="activeTab = 'documents'"
>
<i class="fas fa-file-alt me-2"></i>
Documents
@ -50,8 +50,8 @@
<button
class="nav-link"
:class="{ active: activeTab === 'interventions' }"
@click="activeTab = 'interventions'"
type="button"
@click="activeTab = 'interventions'"
>
<i class="fas fa-clipboard-list me-2"></i>
Interventions
@ -320,8 +320,8 @@
<soft-button
color="primary"
variant="gradient"
@click="saveChanges"
:disabled="isSaving"
@click="saveChanges"
>
<span
v-if="isSaving"
@ -333,8 +333,8 @@
<soft-button
color="secondary"
variant="outline"
@click="cancelEdit"
:disabled="isSaving"
@click="cancelEdit"
>
Annuler
</soft-button>

View File

@ -5,7 +5,7 @@
<i class="fas fa-inbox"></i>
<h3>Aucun défunt trouvé</h3>
<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
</soft-button>
</div>
@ -36,8 +36,8 @@
<button
type="button"
class="btn-close"
@click="closeInterventionModal"
aria-label="Close"
@click="closeInterventionModal"
></button>
</div>
<div class="modal-body">

View File

@ -9,8 +9,8 @@
v-if="selectedFiles.length === 0"
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
@click="triggerFileInput"
:disabled="loading"
@click="triggerFileInput"
>
<i class="fas fa-upload me-2"></i>
Sélectionner
@ -19,8 +19,8 @@
v-else
type="button"
class="btn btn-sm btn-success ms-auto"
@click="uploadFiles"
:disabled="loading"
@click="uploadFiles"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
<i v-else class="fas fa-save me-2"></i>
@ -36,8 +36,8 @@
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.txt"
@change="handleFileSelect"
class="d-none"
@change="handleFileSelect"
/>
</div>
</div>
@ -51,8 +51,8 @@
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="clearSelectedFiles"
:disabled="loading"
@click="clearSelectedFiles"
>
<i class="fas fa-times me-1"></i>
Tout effacer
@ -79,8 +79,8 @@
<button
type="button"
class="btn btn-sm btn-outline-danger"
@click="removeSelectedFile(index)"
:disabled="loading"
@click="removeSelectedFile(index)"
>
<i class="fas fa-times"></i>
</button>
@ -128,8 +128,8 @@
v-if="documents.length > 0"
type="button"
class="btn btn-sm btn-outline-danger ms-auto"
@click="confirmDeleteSelected"
:disabled="selectedDocumentIds.length === 0 || loading"
@click="confirmDeleteSelected"
>
<i class="fas fa-trash me-2"></i>
Supprimer ({{ selectedDocumentIds.length }})
@ -197,10 +197,10 @@
<tr v-for="document in documents" :key="document.id">
<td>
<input
v-model="selectedDocumentIds"
type="checkbox"
class="form-check-input"
:value="document.id"
v-model="selectedDocumentIds"
/>
</td>
<td>
@ -275,8 +275,8 @@
<!-- Edit Label Modal -->
<div
class="modal fade"
id="editLabelModal"
class="modal fade"
tabindex="-1"
aria-labelledby="editLabelModalLabel"
aria-hidden="true"
@ -284,7 +284,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editLabelModalLabel">
<h5 id="editLabelModalLabel" class="modal-title">
Modifier le libellé
</h5>
<button
@ -300,10 +300,10 @@
>Libellé du document</label
>
<input
type="text"
class="form-control"
id="documentLabel"
v-model="editingDocument.label"
type="text"
class="form-control"
placeholder="Entrez un libellé personnalisé"
/>
</div>
@ -319,8 +319,8 @@
<button
type="button"
class="btn btn-primary"
@click="saveDocumentLabel"
:disabled="loading"
@click="saveDocumentLabel"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
Enregistrer

View File

@ -40,8 +40,8 @@
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
@click="toggleEditMode"
:disabled="loading"
@click="toggleEditMode"
>
{{ editMode ? "Sauvegarder" : "Modifier" }}
</button>
@ -51,16 +51,16 @@
<!-- Colonne gauche -->
<div class="col-md-6">
<SoftInput
label="Nom du défunt"
v-model="localIntervention.defuntName"
label="Nom du défunt"
:disabled="!editMode"
class="mb-3"
/>
<SoftInput
v-model="localIntervention.date"
label="Date de l'intervention"
type="datetime-local"
v-model="localIntervention.date"
:disabled="!editMode"
class="mb-3"
/>
@ -76,22 +76,22 @@
<!-- Colonne droite -->
<div class="col-md-6">
<SoftInput
label="Durée prévue"
v-model="localIntervention.duree"
label="Durée prévue"
:disabled="!editMode"
class="mb-3"
/>
<SoftInput
label="Type de cérémonie"
v-model="localIntervention.title"
label="Type de cérémonie"
:disabled="!editMode"
class="mb-3"
/>
<SoftInput
label="Contact familial"
v-model="localIntervention.contactFamilial"
label="Contact familial"
:disabled="!editMode"
class="mb-3"
/>
@ -103,9 +103,9 @@
<div class="mb-4">
<h6 class="mb-3">Description</h6>
<SoftInput
v-model="localIntervention.description"
type="textarea"
rows="3"
v-model="localIntervention.description"
:disabled="!editMode"
placeholder="Description détaillée de l'intervention..."
/>
@ -119,16 +119,16 @@
<button
type="button"
class="btn btn-sm bg-gradient-danger me-2"
@click="resetChanges"
:disabled="loading"
@click="resetChanges"
>
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-success"
@click="saveChanges"
:disabled="loading || !hasChanges"
@click="saveChanges"
>
<i class="fas fa-save me-2"></i>Sauvegarder
</button>
@ -159,8 +159,8 @@
<button
type="button"
class="btn-close"
@click="showTeamModal = false"
aria-label="Close"
@click="showTeamModal = false"
></button>
</div>
<div class="modal-body">

View File

@ -43,8 +43,8 @@
<button
type="button"
class="btn btn-sm btn-outline-primary"
@click="startEditing"
:disabled="loading"
@click="startEditing"
>
<i class="fas fa-edit me-1"></i>
Changer le lieu
@ -56,8 +56,8 @@
<button
type="button"
class="btn btn-sm btn-outline-secondary"
@click="cancelEditing"
:disabled="loading"
@click="cancelEditing"
>
<i class="fas fa-times me-1"></i>
Annuler

View File

@ -18,7 +18,7 @@
</div>
<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
</button>
</div>

View File

@ -3,33 +3,42 @@
<!-- Product Search -->
<div class="col-5">
<div class="position-relative">
<input
type="text"
class="form-control"
placeholder="Rechercher un produit..."
:value="modelValue.product_name"
@input="onSearchInput"
/>
<div v-if="showResults && searchResults.length > 0" class="search-results shadow-sm border rounded">
<ul class="list-group list-group-flush">
<li
v-for="product in searchResults"
:key="product.id"
class="list-group-item list-group-item-action cursor-pointer"
@click="selectProduct(product)"
<input
type="text"
class="form-control"
placeholder="Rechercher un produit..."
:value="modelValue.product_name"
@input="onSearchInput"
/>
<div
v-if="showResults && searchResults.length > 0"
class="search-results shadow-sm border rounded"
>
<ul class="list-group list-group-flush">
<li
v-for="product in searchResults"
:key="product.id"
class="list-group-item list-group-item-action cursor-pointer"
@click="selectProduct(product)"
>
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold">{{ product.nom }}</span>
<br />
<small v-if="product.reference" class="text-muted"
>Ref: {{ product.reference }}</small
>
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold">{{ product.nom }}</span>
<br>
<small class="text-muted" v-if="product.reference">Ref: {{ product.reference }}</small>
</div>
<span class="badge bg-secondary">{{ product.stock_actuel }} en stock</span>
</div>
<small class="text-muted d-block mt-1">{{ formatCurrency(product.prix_unitaire) }} HT</small>
</li>
</ul>
</div>
</div>
<span class="badge bg-secondary"
>{{ product.stock_actuel }} en stock</span
>
</div>
<small class="text-muted d-block mt-1"
>{{ formatCurrency(product.prix_unitaire) }} HT</small
>
</li>
</ul>
</div>
</div>
</div>
@ -47,22 +56,24 @@
<!-- Unit Price -->
<div class="col-2">
<div class="input-group">
<div class="input-group">
<input
type="number"
class="form-control"
placeholder="Prix"
step="0.01"
:value="modelValue.unit_price"
@input="updatePrice($event.target.value)"
type="number"
class="form-control"
placeholder="Prix"
step="0.01"
:value="modelValue.unit_price"
@input="updatePrice($event.target.value)"
/>
<span class="input-group-text"></span>
</div>
</div>
</div>
<!-- Total Line -->
<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>
<!-- Delete Action -->
@ -80,25 +91,25 @@
</template>
<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 { useProductStore } from '@/stores/productStore';
import { useProductStore } from "@/stores/productStore";
const props = defineProps({
modelValue: {
type: Object,
required: true,
default: () => ({
product_id: null,
product_name: '',
quantity: 1,
unit_price: 0,
tva: 20
})
}
product_id: null,
product_name: "",
quantity: 1,
unit_price: 0,
tva: 20,
}),
},
});
const emit = defineEmits(['update:modelValue', 'remove']);
const emit = defineEmits(["update:modelValue", "remove"]);
const productStore = useProductStore();
const searchResults = ref([]);
@ -106,109 +117,112 @@ const showResults = ref(false);
let searchTimeout = null;
const lineTotal = computed(() => {
return props.modelValue.quantity * props.modelValue.unit_price;
return props.modelValue.quantity * props.modelValue.unit_price;
});
const onSearchInput = (event) => {
const query = event.target.value;
// Clear product_id when user types, as it's no longer the selected product
emit('update:modelValue', {
...props.modelValue,
product_name: query,
product_id: null
});
if (searchTimeout) clearTimeout(searchTimeout);
const query = event.target.value;
// Clear product_id when user types, as it's no longer the selected product
emit("update:modelValue", {
...props.modelValue,
product_name: query,
product_id: null,
});
if (query.length < 2) {
if (searchTimeout) clearTimeout(searchTimeout);
if (query.length < 2) {
showResults.value = false;
return;
}
searchTimeout = setTimeout(async () => {
try {
const results = await productStore.searchProducts(query);
console.log("Search results:", results);
if (Array.isArray(results)) {
searchResults.value = results;
showResults.value = true;
} else if (results && results.data && Array.isArray(results.data)) {
// Fallback if the store returns the full response object by mistake
searchResults.value = results.data;
showResults.value = true;
} else {
searchResults.value = [];
showResults.value = false;
return;
}
} catch (e) {
console.error("Search error:", e);
}
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);
}, 300);
};
const selectProduct = (product) => {
emit('update:modelValue', {
...props.modelValue,
product_id: product.id,
product_name: product.nom,
unit_price: product.prix_unitaire,
description: product.description || product.nom // Use description if available
});
showResults.value = false;
emit("update:modelValue", {
...props.modelValue,
product_id: product.id,
product_name: product.nom,
unit_price: product.prix_unitaire,
description: product.description || product.nom, // Use description if available
});
showResults.value = false;
};
// ... existing updateQuantity and updatePrice ...
const updateQuantity = (val) => {
updateField('quantity', parseFloat(val));
updateField("quantity", parseFloat(val));
};
const updatePrice = (val) => {
updateField('unit_price', parseFloat(val));
updateField("unit_price", parseFloat(val));
};
const updateField = (field, value) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
});
emit("update:modelValue", {
...props.modelValue,
[field]: 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
const handleClickOutside = (event) => {
const searchContainer = event.target.closest('.position-relative');
if (!searchContainer && showResults.value) {
showResults.value = false;
}
const searchContainer = event.target.closest(".position-relative");
if (!searchContainer && showResults.value) {
showResults.value = false;
}
};
import { onMounted, onUnmounted } from 'vue';
import { onMounted, onUnmounted } from "vue";
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.search-results {
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 9999;
background: white;
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 9999;
background: white;
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
}
.cursor-pointer {
cursor: pointer;
cursor: pointer;
}
</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>
<div class="card shadow-none border h-100">
<div class="card-header pb-0 p-3">
<h6 class="mb-0">Informations</h6>
<h6 class="mb-0">Informations</h6>
</div>
<div class="card-body p-3">
<ul class="list-group list-group-flush">
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Référence:</span>
<span class="ms-2 text-sm">{{ reference }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Client:</span>
<span class="ms-2 text-sm">{{ clientName }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Date:</span>
<span class="ms-2 text-sm">{{ formatDate(quoteDate) }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Validité:</span>
<span class="ms-2 text-sm">{{ formatDate(validUntil) }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Statut:</span>
<span class="ms-2 badge badge-sm" :class="statusBadgeClass">{{ statusLabel }}</span>
</li>
</ul>
<ul class="list-group list-group-flush">
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Référence:</span>
<span class="ms-2 text-sm">{{ reference }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Client:</span>
<span class="ms-2 text-sm">{{ clientName }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Date:</span>
<span class="ms-2 text-sm">{{ formatDate(quoteDate) }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Validité:</span>
<span class="ms-2 text-sm">{{ formatDate(validUntil) }}</span>
</li>
<li class="list-group-item px-0 pb-3 pt-0 border-0">
<span class="text-sm font-weight-bold text-dark">Statut:</span>
<span class="ms-2 badge badge-sm" :class="statusBadgeClass">{{
statusLabel
}}</span>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { computed, defineProps } from 'vue';
import { computed, defineProps } from "vue";
const props = defineProps({
reference: String,
clientName: String,
quoteDate: String,
validUntil: String,
status: String
reference: String,
clientName: String,
quoteDate: String,
validUntil: String,
status: String,
});
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fr-FR');
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR");
};
const statusBadgeClass = computed(() => {
switch (props.status) {
case 'brouillon': return 'bg-gradient-secondary';
case 'envoye': return 'bg-gradient-info';
case 'accepte': return 'bg-gradient-success';
case 'refuse': return 'bg-gradient-danger';
default: return 'bg-gradient-secondary';
}
switch (props.status) {
case "brouillon":
return "bg-gradient-secondary";
case "envoye":
return "bg-gradient-info";
case "accepte":
return "bg-gradient-success";
case "refuse":
return "bg-gradient-danger";
default:
return "bg-gradient-secondary";
}
});
const statusLabel = computed(() => {
switch (props.status) {
case 'brouillon': return 'Brouillon';
case 'envoye': return 'Envoyé';
case 'accepte': return 'Accepté';
case 'refuse': return 'Refusé';
default: return props.status;
}
switch (props.status) {
case "brouillon":
return "Brouillon";
case "envoye":
return "Envoyé";
case "accepte":
return "Accepté";
case "refuse":
return "Refusé";
default:
return props.status;
}
});
</script>

View File

@ -3,36 +3,78 @@
<table class="table align-items-center mb-0">
<thead>
<tr>
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Produit</th>
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Qté</th>
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Prix Unit.</th>
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">Remise</th>
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2 text-end">Total HT</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Produit
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Qté
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Prix Unit.
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Remise
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2 text-end"
>
Total HT
</th>
</tr>
</thead>
<tbody>
<tr v-for="line in lines" :key="line.id">
<td>
<div class="d-flex flex-column justify-content-center">
<h6 class="mb-0 text-sm">{{ line.product_name || line.description }}</h6>
<p class="text-xs text-secondary mb-0" v-if="line.product && line.product.reference">{{ line.product.reference }}</p>
<h6 class="mb-0 text-sm">
{{ 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>
</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>
<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>
<p class="text-xs font-weight-bold mb-0">{{ line.discount_pct }}%</p>
<td>
<p class="text-xs font-weight-bold mb-0">
{{ line.discount_pct }}%
</p>
</td>
<td class="text-end">
<p class="text-xs font-weight-bold mb-0">{{ formatCurrency(line.total_ht) }}</p>
<p class="text-xs font-weight-bold mb-0">
{{ formatCurrency(line.total_ht) }}
</p>
</td>
</tr>
<tr v-if="!lines || lines.length === 0">
<td colspan="5" class="text-center text-sm py-3">Aucune ligne de produit</td>
<tr v-if="!lines || lines.length === 0">
<td colspan="5" class="text-center text-sm py-3">
Aucune ligne de produit
</td>
</tr>
</tbody>
</table>
@ -40,16 +82,19 @@
</template>
<script setup>
import { defineProps } from 'vue';
import { defineProps } from "vue";
const props = defineProps({
lines: {
type: Array,
default: () => []
}
lines: {
type: Array,
default: () => [],
},
});
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>

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>
<div class="card shadow-none border bg-gray-100">
<div class="card-body p-3">
<div class="d-flex justify-content-between mb-2">
<span class="text-sm text-secondary">Total HT</span>
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.ht) }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-sm text-secondary">TVA</span>
<span class="text-sm font-weight-bold">{{ formatCurrency(totals.tva) }}</span>
</div>
<hr class="horizontal dark my-2">
<div class="d-flex justify-content-between">
<span class="text-base text-dark font-weight-bold">Total TTC</span>
<span class="text-base text-primary font-weight-bold">{{ formatCurrency(totals.ttc) }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-sm text-secondary">Total HT</span>
<span class="text-sm font-weight-bold">{{
formatCurrency(totals.ht)
}}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-sm text-secondary">TVA</span>
<span class="text-sm font-weight-bold">{{
formatCurrency(totals.tva)
}}</span>
</div>
<hr class="horizontal dark my-2" />
<div class="d-flex justify-content-between">
<span class="text-base text-dark font-weight-bold">Total TTC</span>
<span class="text-base text-primary font-weight-bold">{{
formatCurrency(totals.ttc)
}}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import { defineProps } from "vue";
const props = defineProps({
totals: {
type: Object,
required: true,
default: () => ({ ht: 0, tva: 0, ttc: 0 })
}
totals: {
type: Object,
required: true,
default: () => ({ ht: 0, tva: 0, ttc: 0 }),
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value);
};
</script>

View File

@ -110,10 +110,10 @@
<div class="mb-3">
<div class="form-check form-switch">
<input
id="activeSwitch"
v-model="formData.active"
class="form-check-input"
type="checkbox"
id="activeSwitch"
/>
<label class="form-check-label" for="activeSwitch">
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>
<div class="table-container">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Référence</th>
<th>Client</th>
<th>Date</th>
<th>Validité</th>
<th>Total TTC</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Reference Skeleton -->
<td><div class="skeleton-text medium"></div></td>
<!-- Client Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Date Skeleton -->
<td><div class="skeleton-text short"></div></td>
<!-- Validity Skeleton -->
<td><div class="skeleton-text short"></div></td>
<!-- Total Skeleton -->
<td><div class="skeleton-text short"></div></td>
<!-- Status Skeleton -->
<td><div class="skeleton-text short"></div></td>
<!-- Actions Skeleton -->
<td><div class="skeleton-icon small"></div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<div class="card mt-4">
<div class="table-responsive">
<table id="quote-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Référence</th>
<th>Client</th>
<th>Id</th>
<th>Date</th>
<th>Validité</th>
<th>Total TTC</th>
<th>Statut</th>
<th>Client</th>
<th>Produit</th>
<th>Total TTC</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="quote in data" :key="quote.id">
<!-- Reference -->
<td class="text-sm font-weight-bold">
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<span class="my-2 text-xs">{{ quote.reference }}</span>
</div>
<!-- Id (Reference) -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ quote.reference }}
</p>
</div>
</td>
<!-- Client -->
<td class="text-sm font-weight-bold">
<div class="d-flex px-2 py-1">
<div v-if="quote.client">
<soft-avatar
:img="getRandomAvatar()"
size="sm"
class="me-3"
alt="client image"
circular
/>
<div class="d-flex flex-column justify-content-center">
<h6 class="mb-0 text-sm">{{ quote.client.name }}</h6>
<p class="text-xs text-secondary mb-0">{{ quote.client.email }}</p>
</div>
</div>
<span v-else class="text-xs text-secondary">Client Inconnu</span>
</div>
</td>
<!-- Date -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ formatDate(quote.quote_date) }}</span>
</td>
<!-- Validity -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ formatDate(quote.valid_until) }}</span>
</td>
<!-- Total TTC -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ formatCurrency(quote.total_ttc) }}</span>
<!-- Date -->
<td class="font-weight-bold">
<span class="my-2 text-xs">{{
formatDate(quote.quote_date)
}}</span>
</td>
<!-- Status -->
<td class="text-xs font-weight-bold">
<soft-badge :color="getStatusColor(quote.status)" variant="gradient" size="sm">
{{ getStatusLabel(quote.status) }}
</soft-badge>
<div class="d-flex align-items-center">
<soft-button
: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>
<!-- Actions -->
<td class="text-sm">
<div class="d-flex align-items-center gap-2">
<soft-button
color="info"
variant="outline"
title="Voir le devis"
:data-id="quote.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="$emit('view', quote.id)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<soft-button
color="primary"
variant="outline"
title="Modifier le devis"
:data-id="quote.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="$emit('edit', quote.id)"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
</soft-button>
<soft-button
color="danger"
variant="outline"
title="Supprimer le devis"
:data-id="quote.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="$emit('delete', quote.id)"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
<td 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="quote.id"
data-action="view"
title="Voir le devis"
>
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-danger mb-0 px-2"
:data-id="quote.id"
data-action="delete"
title="Supprimer le devis"
>
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-file-invoice-dollar fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun devis trouvé</h5>
<p class="empty-text text-muted">
Aucun devis à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted, defineProps, defineEmits } from "vue";
import {
ref,
onMounted,
watch,
onUnmounted,
defineProps,
defineEmits,
} from "vue";
import { DataTable } from "simple-datatables";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
// Sample avatar images
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 emit = defineEmits(["view", "edit", "delete"]);
const emit = defineEmits(["view", "delete"]);
const props = defineProps({
data: {
@ -198,53 +142,80 @@ const props = defineProps({
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
const dataTableInstance = ref(null);
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const formatDate = (dateString) => {
if (!dateString) return "-";
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return new Date(dateString).toLocaleDateString('fr-FR', options);
if (!dateString) return "-";
const 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) => {
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 colors = {
brouillon: 'secondary',
envoye: 'info',
accepte: 'success',
refuse: 'danger',
expire: 'warning',
annule: 'danger'
};
return colors[status] || 'secondary';
const map = {
brouillon: "secondary",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "warning",
annule: "danger",
};
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 labels = {
brouillon: 'Brouillon',
envoye: 'Envoyé',
accepte: 'Accepté',
refuse: 'Refusé',
expire: 'Expiré',
annule: 'Annulé'
};
return labels[status] || status;
const labels = {
brouillon: "Brouillon",
envoye: "Envoyé",
accepte: "Payé", // "Paid" in the example, assuming Accepted = Paid contextually or mapped
refuse: "Refusé",
expire: "Expiré",
annule: "Annulé",
};
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 = () => {
if (dataTableInstance.value) {
@ -256,18 +227,41 @@ const initializeDataTable = () => {
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 10,
labels: {
placeholder: "Rechercher...",
perPage: "{select} entrées par page",
noRows: "Aucune entrée trouvée",
info: "Affichage de {start} à {end} sur {rows} entrées",
},
fixedHeight: false, // Changed from true to false as per request
perPageSelect: false, // Changed as per request
});
}
};
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(
() => props.data,
() => {
@ -285,84 +279,4 @@ onUnmounted(() => {
dataTableInstance.value.destroy();
}
});
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text.short { width: 40px; }
.skeleton-text.medium { width: 80px; }
.skeleton-text.long { width: 120px; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
</style>

View File

@ -5,7 +5,7 @@
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<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>
Ajouter un document
</button>
@ -47,22 +47,22 @@
<div class="mt-3">
<div class="btn-group w-100" role="group">
<button
@click="downloadDocument(document)"
class="btn btn-outline-primary btn-sm"
@click="downloadDocument(document)"
>
<i class="fas fa-download me-1"></i>
Télécharger
</button>
<button
@click="editDocument(document)"
class="btn btn-outline-info btn-sm"
@click="editDocument(document)"
>
<i class="fas fa-edit me-1"></i>
Modifier
</button>
<button
@click="deleteDocument(document.id)"
class="btn btn-outline-danger btn-sm"
@click="deleteDocument(document.id)"
>
<i class="fas fa-trash me-1"></i>
Supprimer
@ -85,7 +85,7 @@
<p class="text-muted">
Commencez par ajouter des documents pour ce praticien.
</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>
Ajouter le premier document
</button>
@ -116,7 +116,7 @@
></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveDocument" novalidate>
<form novalidate @submit.prevent="saveDocument">
<div class="row">
<div class="col-12 mb-3">
<div class="form-group">
@ -220,8 +220,8 @@
<button
type="button"
class="btn btn-primary"
@click="saveDocument"
:disabled="isDocumentLoading"
@click="saveDocument"
>
<span
v-if="isDocumentLoading"

View File

@ -8,7 +8,7 @@
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" novalidate>
<form novalidate @submit.prevent="handleSubmit">
<div class="row">
<!-- Personal Information -->
<div class="col-12 mb-4">
@ -184,8 +184,8 @@
<soft-button
type="button"
class="btn btn-light me-3"
@click="resetForm"
:disabled="isLoading"
@click="resetForm"
>
Annuler
</soft-button>

View File

@ -7,8 +7,8 @@
<h6 class="mb-0">Aperçu de l'employé</h6>
<div>
<button
@click="$emit('view-info-tab')"
class="btn btn-sm btn-outline-primary"
@click="$emit('view-info-tab')"
>
<i class="fas fa-edit me-1"></i>
Modifier

View File

@ -14,8 +14,8 @@
<button
type="button"
class="btn-close"
@click="close"
aria-label="Fermer"
@click="close"
></button>
</div>
<div class="modal-body">

View File

@ -2,19 +2,19 @@
<div class="practitioner-search-input">
<div class="input-group mb-3">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Rechercher un praticien..."
v-model="searchQuery"
:disabled="loading"
@input="handleInput"
@keyup.enter="handleSearch"
:disabled="loading"
/>
<button
class="btn btn-outline-primary"
type="button"
@click="handleSearch"
:disabled="loading || !searchQuery.trim()"
@click="handleSearch"
>
<i v-if="!loading" class="fas fa-search"></i>
<span
@ -50,7 +50,7 @@
<div class="flex-grow-1">
<h6 class="mb-0 text-sm">{{ practitioner.full_name }}</h6>
<p class="text-xs text-muted mb-0">
{{ practitioner.job_title || 'Praticien' }}
{{ practitioner.job_title || "Praticien" }}
</p>
</div>
</div>
@ -70,7 +70,7 @@
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import { ref, defineProps, defineEmits } from "vue";
const props = defineProps({
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 = () => {
// Optional: Implement debounce here if needed
@ -94,13 +94,13 @@ const handleInput = () => {
const handleSearch = () => {
if (searchQuery.value.trim()) {
emit('search', searchQuery.value.trim());
emit("search", searchQuery.value.trim());
}
};
const handleSelect = (practitioner) => {
emit('select', practitioner);
searchQuery.value = ''; // Clear search after selection
emit("select", practitioner);
searchQuery.value = ""; // Clear search after selection
};
</script>

View File

@ -7,7 +7,9 @@
<div class="d-lg-flex">
<div>
<h5 class="mb-0">Nouveau Devis</h5>
<p class="text-sm mb-0">Créer un nouveau devis pour un client.</p>
<p class="text-sm mb-0">
Créer un nouveau devis pour un client.
</p>
</div>
<div class="ms-auto my-auto mt-lg-0 mt-4">
<div class="ms-auto my-auto">
@ -18,33 +20,33 @@
</div>
<div class="card-body">
<div class="row">
<!-- Client Selection -->
<!-- Client Selection -->
<div class="col-12 col-lg-4 mb-4">
<slot name="client-selection"></slot>
<slot name="client-selection"></slot>
</div>
<!-- Quote Details -->
<div class="col-12 col-lg-8 mb-4">
<slot name="quote-details"></slot>
<slot name="quote-details"></slot>
</div>
</div>
<hr class="horizontal dark my-4">
<hr class="horizontal dark my-4" />
<!-- Product Lines -->
<div class="row">
<div class="col-12">
<h6 class="mb-3">Produits & Services</h6>
<slot name="product-lines"></slot>
</div>
<div class="col-12">
<h6 class="mb-3">Produits & Services</h6>
<slot name="product-lines"></slot>
</div>
</div>
<hr class="horizontal dark my-4">
<hr class="horizontal dark my-4" />
<!-- Totals -->
<div class="row">
<div class="col-12 col-lg-4 ms-auto">
<slot name="totals"></slot>
</div>
<div class="col-12 col-lg-4 ms-auto">
<slot name="totals"></slot>
</div>
</div>
</div>
</div>
@ -53,5 +55,4 @@
</div>
</template>
<script setup>
</script>
<script setup></script>

View File

@ -1,38 +1,46 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="col-lg-8 mx-auto">
<div class="card mb-4">
<div class="card-header pb-0 p-3">
<div class="d-flex justify-content-between align-items-center">
<slot name="header"></slot>
</div>
<slot name="header"></slot>
<div class="card-body p-3 pt-0">
<hr class="horizontal dark mt-0 mb-4" />
<!-- Product Lines Section (replacing Gold Glasses) -->
<div class="row">
<div class="col-12">
<slot name="lines"></slot>
</div>
</div>
<div class="card-body p-3">
<div class="row">
<div class="col-12 col-md-4 mb-4">
<slot name="info"></slot>
</div>
<div class="col-12 col-md-8">
<h6 class="text-uppercase text-body text-xs font-weight-bolder mb-3">Détail des lignes</h6>
<slot name="lines"></slot>
<div class="row mt-4">
<div class="col-md-6 ms-auto">
<slot name="totals"></slot>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer p-3">
<slot name="actions"></slot>
<hr class="horizontal dark mt-4 mb-4" />
<div class="row">
<!-- Tracking/Timeline Section -->
<div class="col-lg-3 col-md-6 col-12">
<slot name="timeline"></slot>
</div>
<!-- Billing Info Section -->
<div class="col-lg-5 col-md-6 col-12">
<slot name="billing"></slot>
</div>
<!-- Summary Section -->
<div class="col-lg-3 col-12 ms-auto">
<slot name="summary"></slot>
</div>
</div>
</div>
<div class="card-footer p-3">
<slot name="actions"></slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
</script>
<script setup></script>

View File

@ -185,10 +185,10 @@
<div class="input-container">
<div class="input-wrapper">
<input
v-model="userMessage"
type="text"
placeholder="Tapez votre message..."
class="message-input"
v-model="userMessage"
@keyup.enter="sendMessage"
/>
<button class="send-btn" @click="sendMessage">

View File

@ -170,6 +170,12 @@ export default {
miniIcon: "C",
text: "Clients",
},
{
id: "clients-groups",
route: { name: "ClientGroups" },
miniIcon: "G",
text: "Groupes",
},
{
id: "clients-locations",
route: { name: "Localisation clients" },

View File

@ -497,6 +497,27 @@ const routes = [
name: "Nouveau Devis",
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",
name: "Statistiques ventes",
@ -629,8 +650,7 @@ const routes = [
{
path: "/parametrage/categories-produits",
name: "Product Categories",
component: () =>
import("@/views/pages/Parametrage/ProductCategories.vue"),
component: () => 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;
group_id: number | null;
reference: string;
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
quote_date: string;
valid_until: string | null;
currency: string;
@ -52,7 +52,7 @@ export interface QuoteLine {
export interface CreateQuotePayload {
client_id: number;
group_id?: number | null;
status: 'brouillon' | 'envoye' | 'accepte' | 'refuse' | 'expire' | 'annule';
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
quote_date: string;
valid_until?: string | null;
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.error = null;
try {
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(params);
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(
params
);
this.categories = response.data;
if (response.pagination) {
this.meta = {
@ -57,7 +59,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
}
return response;
} 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;
} finally {
this.loading = false;
@ -78,7 +81,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
this.currentCategory = response.data;
return response.data;
} 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;
} finally {
this.loading = false;
@ -89,12 +93,15 @@ export const useProductCategoryStore = defineStore("productCategory", {
this.isLoading = true;
this.error = null;
try {
const response = await productCategoryService.createProductCategory(data);
const response = await productCategoryService.createProductCategory(
data
);
this.categories.unshift(response.data);
this.meta.total++;
return response.data;
} 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;
} finally {
this.isLoading = false;
@ -105,7 +112,10 @@ export const useProductCategoryStore = defineStore("productCategory", {
this.isLoading = true;
this.error = null;
try {
const response = await productCategoryService.updateProductCategory(id, data);
const response = await productCategoryService.updateProductCategory(
id,
data
);
const index = this.categories.findIndex((c) => c.id === id);
if (index !== -1) {
this.categories[index] = response.data;
@ -115,7 +125,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
}
return response.data;
} 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;
} finally {
this.isLoading = false;
@ -134,7 +145,8 @@ export const useProductCategoryStore = defineStore("productCategory", {
}
return true;
} 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;
} finally {
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,14 +25,14 @@
</div>
<div class="card-body px-0 pb-0">
<div class="px-4">
<!-- You could add search here -->
<!-- You could add search here -->
</div>
<ProductCategoryList
:categories="store.categories"
@edit="openEditModal"
@delete="handleDelete"
/>
<!-- Pagination if needed -->
<!-- <div class="px-4 py-3 border-top d-flex justify-content-end">
Pagination component
@ -101,16 +101,16 @@ const handleCloseModal = () => {
const handleSave = async (formData) => {
try {
if (formData.id) {
await store.updateCategory(formData.id, formData);
Swal.fire("Succès", "Catégorie mise à jour avec succès", "success");
await store.updateCategory(formData.id, formData);
Swal.fire("Succès", "Catégorie mise à jour avec succès", "success");
} else {
await store.createCategory(formData);
Swal.fire("Succès", "Catégorie créée avec succès", "success");
// Update all categories list for dropdowns if new one created
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
await store.createCategory(formData);
Swal.fire("Succès", "Catégorie créée avec succès", "success");
// Update all categories list for dropdowns if new one created
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
}
if (modalComponent.value) {
modalComponent.value.hide();
}
@ -135,9 +135,9 @@ const handleDelete = async (id) => {
try {
await store.deleteCategory(id);
Swal.fire("Supprimé !", "La catégorie a été supprimée.", "success");
// Update all categories list for dropdowns
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
// Update all categories list for dropdowns
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
} catch (error) {
Swal.fire("Erreur", error.message || "Impossible de supprimer", "error");
}

View File

@ -15,7 +15,7 @@
</div>
<div v-else class="text-center py-4">
<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>
</template>

View File

@ -3,5 +3,5 @@
</template>
<script setup>
import QuoteListPresentation from '@/components/Organism/Quote/QuoteListPresentation.vue';
import QuoteListPresentation from "@/components/Organism/Quote/QuoteListPresentation.vue";
</script>

View File

@ -3,5 +3,5 @@
</template>
<script setup>
import QuoteCreationPresentation from '@/components/Organism/Quote/QuoteCreationPresentation.vue';
import QuoteCreationPresentation from "@/components/Organism/Quote/QuoteCreationPresentation.vue";
</script>

View File

@ -3,9 +3,9 @@
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import QuoteDetailPresentation from '@/components/Organism/Quote/QuoteDetailPresentation.vue';
import { computed } from "vue";
import { useRoute } from "vue-router";
import QuoteDetailPresentation from "@/components/Organism/Quote/QuoteDetailPresentation.vue";
const route = useRoute();
const quoteId = computed(() => route.params.id);