add notification et crud fournisseur

This commit is contained in:
Nyavokevin 2025-10-29 17:17:50 +03:00
parent ca09f6da2f
commit 4b056038d6
33 changed files with 3006 additions and 273 deletions

View File

@ -187,4 +187,28 @@ class ContactController extends Controller
], 500);
}
}
public function getContactsByFournisseur(string $fournisseurId): JsonResponse
{
try {
$intId = (int) $fournisseurId;
$contacts = $this->contactRepository->getByFournisseurId($intId);
return response()->json([
'data' => ContactResource::collection($contacts),
], 200);
} catch (\Exception $e) {
Log::error('Error fetching contacts by fournisseur: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'fournisseur_id' => $fournisseurId,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des contacts du fournisseur.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -22,7 +22,8 @@ class StoreContactRequest extends FormRequest
public function rules(): array
{
return [
'client_id' => 'required|exists:clients,id',
'client_id' => 'nullable|exists:clients,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'first_name' => 'nullable|string|max:191',
'last_name' => 'nullable|string|max:191',
'email' => 'nullable|email|max:191',
@ -34,8 +35,8 @@ class StoreContactRequest extends FormRequest
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné n\'existe pas.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',
'first_name.string' => 'Le prénom doit être une chaîne de caractères.',
'first_name.max' => 'Le prénom ne peut pas dépasser 191 caractères.',
'last_name.string' => 'Le nom doit être une chaîne de caractères.',
@ -50,9 +51,17 @@ class StoreContactRequest extends FormRequest
public function withValidator($validator)
{
$validator->after(function ($validator) {
// At least one of client_id or fournisseur_id must be provided
if (empty($this->client_id) && empty($this->fournisseur_id)) {
$validator->errors()->add(
'general',
'Le contact doit être associé à un client ou un fournisseur.'
);
}
if (empty($this->first_name) && empty($this->last_name) && empty($this->email) && empty($this->phone)) {
$validator->errors()->add(
'general',
'general',
'Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.'
);
}

View File

@ -22,7 +22,8 @@ class UpdateContactRequest extends FormRequest
public function rules(): array
{
return [
'client_id' => 'required|exists:clients,id',
'client_id' => 'nullable|exists:clients,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'first_name' => 'nullable|string|max:191',
'last_name' => 'nullable|string|max:191',
'email' => 'nullable|email|max:191',
@ -34,8 +35,8 @@ class UpdateContactRequest extends FormRequest
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné n\'existe pas.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',
'first_name.string' => 'Le prénom doit être une chaîne de caractères.',
'first_name.max' => 'Le prénom ne peut pas dépasser 191 caractères.',
'last_name.string' => 'Le nom doit être une chaîne de caractères.',
@ -51,9 +52,17 @@ class UpdateContactRequest extends FormRequest
public function withValidator($validator)
{
$validator->after(function ($validator) {
// At least one of client_id or fournisseur_id must be provided
if (empty($this->client_id) && empty($this->fournisseur_id)) {
$validator->errors()->add(
'general',
'Le contact doit être associé à un client ou un fournisseur.'
);
}
if (empty($this->first_name) && empty($this->last_name) && empty($this->email) && empty($this->phone)) {
$validator->errors()->add(
'general',
'general',
'Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.'
);
}

View File

@ -18,6 +18,7 @@ class ContactResource extends JsonResource
return [
'id' => $this->id,
'client_id' => $this->client_id,
'fournisseur_id' => $this->fournisseur_id,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
@ -28,10 +29,18 @@ class ContactResource extends JsonResource
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'client' => $this->whenLoaded('client', [
'id' => $this->client->id,
'name' => $this->client->name,
]),
'client' => $this->whenLoaded('client', function() {
return $this->client ? [
'id' => $this->client->id,
'name' => $this->client->name,
] : null;
}),
'fournisseur' => $this->whenLoaded('fournisseur', function() {
return $this->fournisseur ? [
'id' => $this->fournisseur->id,
'name' => $this->fournisseur->name,
] : null;
}),
];
}

View File

@ -16,7 +16,8 @@ class Contact extends Model
'position',
'notes',
'is_primary',
'client_id'
'client_id',
'fournisseur_id'
];
protected $casts = [
@ -28,6 +29,11 @@ class Contact extends Model
return $this->belongsTo(Client::class);
}
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
/**
* Get the contact's full name.
*/

View File

@ -62,4 +62,11 @@ class ContactRepository extends BaseRepository implements ContactRepositoryInter
->where('client_id', $clientId)
->get();
}
public function getByFournisseurId(int $fournisseurId)
{
return $this->model->newQuery()
->where('fournisseur_id', $fournisseurId)
->get();
}
}

View File

@ -9,4 +9,6 @@ interface ContactRepositoryInterface extends BaseRepositoryInterface
function paginate(int $perPage = 15, array $filters = []);
function getByClientId(int $clientId);
function getByFournisseurId(int $fournisseurId);
}

View File

@ -0,0 +1,41 @@
<?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::table('contacts', function (Blueprint $table) {
// Make client_id nullable and remove cascade
$table->dropForeign(['client_id']);
$table->foreignId('client_id')->nullable()->change();
$table->foreign('client_id')->references('id')->on('clients')->onDelete('set null');
// Add fournisseur_id
$table->foreignId('fournisseur_id')->nullable()->after('client_id')->constrained('fournisseurs')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contacts', function (Blueprint $table) {
// Remove fournisseur_id
$table->dropForeign(['fournisseur_id']);
$table->dropColumn('fournisseur_id');
// Restore client_id to not nullable with cascade
$table->dropForeign(['client_id']);
$table->foreignId('client_id')->change();
$table->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
});
}
};

View File

@ -53,4 +53,5 @@ Route::middleware('auth:sanctum')->group(function () {
// Fournisseur management
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
Route::apiResource('fournisseurs', FournisseurController::class);
Route::get('fournisseurs/{fournisseurId}/contacts', [ContactController::class, 'getContactsByFournisseur']);
});

View File

@ -0,0 +1,252 @@
<template>
<teleport to="body">
<div class="notification-container">
<transition-group name="notification" tag="div">
<div
v-for="notification in notificationStore.notifications"
:key="notification.id"
:class="['notification-item', `notification-${notification.type}`]"
@click="notificationStore.removeNotification(notification.id)"
>
<div class="notification-content">
<div class="notification-icon">
<i :class="getIcon(notification.type)"></i>
</div>
<div class="notification-text">
<h6 class="notification-title mb-1">{{ notification.title }}</h6>
<p class="notification-message mb-0">
{{ notification.message }}
</p>
</div>
<button
class="notification-close"
@click.stop="
notificationStore.removeNotification(notification.id)
"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="notification-progress">
<div
class="notification-progress-bar"
:style="{ animationDuration: `${notification.duration}ms` }"
></div>
</div>
</div>
</transition-group>
</div>
</teleport>
</template>
<script setup>
import { useNotificationStore } from "@/stores/notification";
const notificationStore = useNotificationStore();
const getIcon = (type) => {
const icons = {
success: "fas fa-check-circle",
error: "fas fa-exclamation-circle",
warning: "fas fa-exclamation-triangle",
info: "fas fa-info-circle",
};
return icons[type] || icons.info;
};
</script>
<style scoped>
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
.notification-item {
pointer-events: all;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border-left: 4px solid;
}
.notification-item:hover {
transform: translateX(-5px);
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
}
.notification-success {
border-left-color: #28a745;
}
.notification-error {
border-left-color: #dc3545;
}
.notification-warning {
border-left-color: #ffc107;
}
.notification-info {
border-left-color: #17a2b8;
}
.notification-content {
display: flex;
align-items: flex-start;
padding: 16px;
gap: 12px;
}
.notification-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 16px;
}
.notification-success .notification-icon {
background-color: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.notification-error .notification-icon {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.notification-warning .notification-icon {
background-color: rgba(255, 193, 7, 0.1);
color: #ffc107;
}
.notification-info .notification-icon {
background-color: rgba(23, 162, 184, 0.1);
color: #17a2b8;
}
.notification-text {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #344767;
margin: 0;
}
.notification-message {
font-size: 13px;
color: #67748e;
margin: 0;
line-height: 1.4;
}
.notification-close {
flex-shrink: 0;
background: none;
border: none;
color: #67748e;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.notification-close:hover {
background-color: rgba(0, 0, 0, 0.05);
color: #344767;
}
.notification-progress {
height: 3px;
background-color: rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.notification-progress-bar {
height: 100%;
background-color: currentColor;
animation: progress linear forwards;
transform-origin: left;
}
.notification-success .notification-progress-bar {
color: #28a745;
}
.notification-error .notification-progress-bar {
color: #dc3545;
}
.notification-warning .notification-progress-bar {
color: #ffc107;
}
.notification-info .notification-progress-bar {
color: #17a2b8;
}
@keyframes progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* Transition animations */
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.notification-move {
transition: transform 0.3s ease;
}
/* Responsive */
@media (max-width: 768px) {
.notification-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.notification-item {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<new-fournisseur-template>
<template #multi-step> </template>
<template #fournisseur-form>
<new-fournisseur-form
:loading="loading"
:validation-errors="validationErrors"
:success="success"
@create-fournisseur="handleCreateFournisseur"
/>
</template>
</new-fournisseur-template>
</template>
<script setup>
import NewFournisseurTemplate from "@/components/templates/CRM/NewFournisseurTemplate.vue";
import NewFournisseurForm from "@/components/molecules/form/NewFournisseurForm.vue";
import { defineProps, defineEmits } from "vue";
defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["createFournisseur"]);
const handleCreateFournisseur = (data) => {
emit("createFournisseur", data);
};
</script>

View File

@ -23,7 +23,7 @@
:initials="getInitials(fournisseur.name)"
:fournisseur-name="fournisseur.name"
:fournisseur-type="fournisseur.type_label || 'Fournisseur'"
:contacts-count="contacts.length"
:contacts-count="filteredContactsCount"
:locations-count="locations.length"
:is-active="fournisseur.is_active"
:active-tab="activeTab"
@ -41,10 +41,9 @@
/>
</template>
<template #fournisseur-detail-content>
<FournisseurDetailContent
<fournisseur-detail-content
:active-tab="activeTab"
:fournisseur="fournisseur"
:contacts="contacts"
:locations="locations"
:formatted-address="formatAddress(fournisseur)"
:fournisseur-id="fournisseur.id"
@ -54,6 +53,7 @@
@updating-fournisseur="handleUpdateFournisseur"
@create-contact="handleAddContact"
@updating-contact="handleModifiedContact"
@contact-removed="handleRemovedContact"
@create-location="handleAddLocation"
@modify-location="handleModifyLocation"
@remove-location="handleRemoveLocation"
@ -62,7 +62,8 @@
</fournisseur-detail-template>
</template>
<script setup>
import { defineProps, defineEmits, ref } from "vue";
import { defineProps, defineEmits, ref, computed } from "vue";
import { useContactStore } from "@/stores/contactStore";
import FournisseurDetailTemplate from "@/components/templates/CRM/FournisseurDetailTemplate.vue";
import FournisseurDetailSidebar from "./fournisseur/FournisseurDetailSidebar.vue";
import FournisseurDetailContent from "./fournisseur/FournisseurDetailContent.vue";
@ -73,11 +74,6 @@ const props = defineProps({
type: Object,
required: true,
},
contacts: {
type: Array,
required: false,
default: () => [],
},
locations: {
type: Array,
required: false,
@ -87,6 +83,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
contactIsLoading: {
type: Boolean,
default: false,
},
fournisseurAvatar: {
type: String,
default: "",
@ -109,6 +109,15 @@ const props = defineProps({
},
});
// Use contact store to get filtered contacts count
const contactStore = useContactStore();
const filteredContactsCount = computed(() => {
return contactStore.contacts.filter(
(contact) => contact.fournisseur_id === props.fournisseur.id
).length;
});
const localAvatar = ref(props.fournisseurAvatar);
const emit = defineEmits([
@ -116,6 +125,7 @@ const emit = defineEmits([
"handleFileInput",
"add-new-contact",
"updating-contact",
"contact-removed",
"add-new-location",
"modify-location",
"remove-location",
@ -150,6 +160,10 @@ const handleModifiedContact = (modifiedContact) => {
emit("updating-contact", modifiedContact);
};
const handleRemovedContact = (contactId) => {
emit("contact-removed", contactId);
};
const handleAddLocation = (data) => {
emit("add-new-location", data);
};

View File

@ -44,8 +44,9 @@ defineProps({
});
const goToFournisseur = () => {
// Navigate to create fournisseur page when implemented
console.log("Navigate to create fournisseur");
router.push({
name: "Creation fournisseur",
});
};
const goToDetails = (fournisseurId) => {

View File

@ -4,7 +4,6 @@
<div v-show="activeTab === 'overview'">
<FournisseurOverview
:fournisseur="fournisseur"
:contacts="contacts"
:formatted-address="formattedAddress"
:fournisseur-id="fournisseurId"
@view-all-contacts="$emit('change-tab', 'contacts')"
@ -26,11 +25,11 @@
<!-- Contacts Tab -->
<div v-show="activeTab === 'contacts'">
<FournisseurContactsTab
:contacts="contacts"
:fournisseur-id="fournisseur.id"
:is-loading="contactIsLoading"
@contact-created="handleCreateContact"
@contact-modified="handleModifiedContact"
@contact-removed="handleRemovedContact"
/>
</div>
@ -77,10 +76,6 @@ defineProps({
type: Object,
required: true,
},
contacts: {
type: Array,
default: () => [],
},
locations: {
type: Array,
default: () => [],
@ -111,6 +106,7 @@ const emit = defineEmits([
"modify-location",
"remove-location",
"updating-contact",
"contact-removed",
]);
const updateFournisseur = (updatedFournisseur) => {
@ -136,4 +132,10 @@ const handleModifyLocation = (location) => {
const handleRemoveLocation = (locationId) => {
emit("remove-location", locationId);
};
const handleRemovedContact = (contactId) => {
// The contact is already removed from the store,
// so we just need to notify parent components if needed
emit("contact-removed", contactId);
};
</script>

View File

@ -3,19 +3,6 @@
<h5 class="font-weight-bolder mb-0">Nouveau Client</h5>
<p class="mb-0 text-sm">Informations du client</p>
<!-- Message de succès -->
<div
v-if="props.success"
class="alert alert-success alert-dismissible fade show mt-3"
role="alert"
>
<span class="alert-icon"><i class="ni ni-like-2"></i></span>
<span class="alert-text"
><strong>Succès !</strong> Client créé avec succès ! Redirection en
cours...</span
>
</div>
<div class="multisteps-form__content">
<!-- Catégorie du client -->
<div class="row mt-3">

View File

@ -0,0 +1,486 @@
<template>
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<h5 class="font-weight-bolder mb-0">Nouveau Fournisseur</h5>
<p class="mb-0 text-sm">Informations du fournisseur</p>
<div class="multisteps-form__content">
<!-- Nom du fournisseur -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label"
>Nom du fournisseur <span class="text-danger">*</span></label
>
<soft-input
:value="form.name"
@input="form.name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.name }"
type="text"
placeholder="ex. Nom de l'entreprise"
/>
<div v-if="fieldErrors.name" class="invalid-feedback">
{{ fieldErrors.name }}
</div>
</div>
</div>
<!-- TVA & SIRET -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Numéro de TVA</label>
<soft-input
:value="form.vat_number"
@input="form.vat_number = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.vat_number }"
type="text"
placeholder="ex. FR12345678901"
maxlength="32"
/>
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
{{ fieldErrors.vat_number }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">SIRET</label>
<soft-input
:value="form.siret"
@input="form.siret = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.siret }"
type="text"
placeholder="ex. 12345678901234"
maxlength="20"
/>
<div v-if="fieldErrors.siret" class="invalid-feedback">
{{ fieldErrors.siret }}
</div>
</div>
</div>
<!-- Informations de contact -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Email</label>
<soft-input
:value="form.email"
@input="form.email = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.email }"
type="email"
placeholder="ex. contact@fournisseur.com"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
@input="form.phone = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +33 1 23 45 67 89"
maxlength="50"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
</div>
</div>
</div>
<!-- Adresse de facturation -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Adresse ligne 1</label>
<soft-input
:value="form.billing_address_line1"
@input="form.billing_address_line1 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
type="text"
placeholder="ex. 123 Rue Principale"
maxlength="255"
/>
<div
v-if="fieldErrors.billing_address_line1"
class="invalid-feedback"
>
{{ fieldErrors.billing_address_line1 }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Adresse ligne 2</label>
<soft-input
:value="form.billing_address_line2"
@input="form.billing_address_line2 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
type="text"
placeholder="ex. Bâtiment, Étage, etc."
maxlength="255"
/>
<div
v-if="fieldErrors.billing_address_line2"
class="invalid-feedback"
>
{{ fieldErrors.billing_address_line2 }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-sm-4">
<label class="form-label">Code postal</label>
<soft-input
:value="form.billing_postal_code"
@input="form.billing_postal_code = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
type="text"
placeholder="ex. 75001"
maxlength="20"
/>
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
{{ fieldErrors.billing_postal_code }}
</div>
</div>
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
<label class="form-label">Ville</label>
<soft-input
:value="form.billing_city"
@input="form.billing_city = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_city }"
type="text"
placeholder="ex. Paris"
maxlength="191"
/>
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
{{ fieldErrors.billing_city }}
</div>
</div>
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
<label class="form-label">Code pays</label>
<select
:value="form.billing_country_code"
@input="form.billing_country_code = $event.target.value"
class="form-control multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
>
<option value="">Sélectionner un pays</option>
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="CH">Suisse</option>
<option value="LU">Luxembourg</option>
<option value="DE">Allemagne</option>
<option value="ES">Espagne</option>
<option value="IT">Italie</option>
<option value="GB">Royaume-Uni</option>
<option value="MG">Madagascar</option>
</select>
<div v-if="fieldErrors.billing_country_code" class="invalid-feedback">
{{ fieldErrors.billing_country_code }}
</div>
</div>
</div>
<!-- Notes -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Notes</label>
<textarea
:value="form.notes"
@input="form.notes = $event.target.value"
class="form-control multisteps-form__input"
rows="3"
placeholder="Notes supplémentaires sur le fournisseur..."
maxlength="1000"
></textarea>
</div>
</div>
<!-- Statut -->
<div class="row mt-3">
<div class="col-12">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="isActive"
:checked="form.is_active"
@change="form.is_active = $event.target.checked"
/>
<label class="form-check-label" for="isActive">
Fournisseur actif
</label>
</div>
</div>
</div>
<!-- Boutons -->
<div class="button-row d-flex mt-4">
<soft-button
type="button"
color="secondary"
variant="outline"
class="me-2 mb-0"
@click="resetForm"
>
Réinitialiser
</soft-button>
<soft-button
type="button"
color="dark"
variant="gradient"
class="ms-auto mb-0"
:disabled="props.loading"
@click="submitForm"
>
<span
v-if="props.loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{{ props.loading ? "Création..." : "Créer le fournisseur" }}
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
// Props
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
// Emits
const emit = defineEmits(["createFournisseur"]);
// Reactive data
const errors = ref([]);
const fieldErrors = ref({});
const form = ref({
name: "",
vat_number: "",
siret: "",
email: "",
phone: "",
billing_address_line1: "",
billing_address_line2: "",
billing_postal_code: "",
billing_city: "",
billing_country_code: "",
notes: "",
is_active: true,
});
// Watch for validation errors from parent
watch(
() => props.validationErrors,
(newErrors) => {
fieldErrors.value = { ...newErrors };
},
{ deep: true }
);
// Watch for success from parent
watch(
() => props.success,
(newSuccess) => {
if (newSuccess) {
resetForm();
}
}
);
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Client-side validation
const validationResult = validateForm();
if (!validationResult.isValid) {
fieldErrors.value = validationResult.errors;
return;
}
// Clean up form data: convert empty strings to null
const cleanedForm = {};
const formData = form.value;
for (const [key, value] of Object.entries(formData)) {
if (value === "" || value === null || value === undefined) {
cleanedForm[key] = null;
} else {
cleanedForm[key] = value;
}
}
// Ensure is_active is boolean
cleanedForm.is_active = Boolean(formData.is_active);
console.log("Form data being emitted:", cleanedForm);
// Emit the cleaned form data to parent
emit("createFournisseur", cleanedForm);
};
const validateForm = () => {
const errors = {};
let isValid = true;
// Nom requis
if (!form.value.name || form.value.name.trim() === "") {
errors.name = "Le nom du fournisseur est obligatoire";
isValid = false;
} else if (form.value.name.length > 255) {
errors.name = "Le nom ne peut pas dépasser 255 caractères";
isValid = false;
}
// Email validation
if (form.value.email && form.value.email.trim() !== "") {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(form.value.email)) {
errors.email = "L'adresse email doit être valide";
isValid = false;
} else if (form.value.email.length > 191) {
errors.email = "L'email ne peut pas dépasser 191 caractères";
isValid = false;
}
}
// VAT number validation
if (form.value.vat_number && form.value.vat_number.length > 32) {
errors.vat_number = "Le numéro de TVA ne peut pas dépasser 32 caractères";
isValid = false;
}
// SIRET validation
if (form.value.siret && form.value.siret.length > 20) {
errors.siret = "Le SIRET ne peut pas dépasser 20 caractères";
isValid = false;
}
// Phone validation
if (form.value.phone && form.value.phone.length > 50) {
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères";
isValid = false;
}
// Address validations
if (
form.value.billing_address_line1 &&
form.value.billing_address_line1.length > 255
) {
errors.billing_address_line1 =
"L'adresse ne peut pas dépasser 255 caractères";
isValid = false;
}
if (
form.value.billing_address_line2 &&
form.value.billing_address_line2.length > 255
) {
errors.billing_address_line2 =
"Le complément d'adresse ne peut pas dépasser 255 caractères";
isValid = false;
}
if (
form.value.billing_postal_code &&
form.value.billing_postal_code.length > 20
) {
errors.billing_postal_code =
"Le code postal ne peut pas dépasser 20 caractères";
isValid = false;
}
if (form.value.billing_city && form.value.billing_city.length > 191) {
errors.billing_city = "La ville ne peut pas dépasser 191 caractères";
isValid = false;
}
if (
form.value.billing_country_code &&
form.value.billing_country_code.length !== 2
) {
errors.billing_country_code =
"Le code pays doit contenir exactement 2 caractères";
isValid = false;
}
return { isValid, errors };
};
const resetForm = () => {
form.value = {
name: "",
vat_number: "",
siret: "",
email: "",
phone: "",
billing_address_line1: "",
billing_address_line2: "",
billing_postal_code: "",
billing_city: "",
billing_country_code: "",
notes: "",
is_active: true,
};
clearErrors();
};
const clearErrors = () => {
errors.value = [];
fieldErrors.value = {};
};
</script>
<style scoped>
.form-label {
font-weight: 600;
margin-bottom: 0.5rem;
}
.text-danger {
color: #f5365c;
}
.invalid-feedback {
display: block;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
.alert {
border-radius: 0.75rem;
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<!-- Modal Component -->
<div
class="modal fade"
:class="{ show: isVisible, 'd-block': isVisible }"
tabindex="-1"
role="dialog"
:aria-hidden="!isVisible"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h5 class="modal-title">
<i
:class="
isModification ? 'fas fa-edit me-2' : 'fas fa-user-plus me-2'
"
></i>
{{ isModification ? "Modifier le contact" : "Ajouter un contact" }}
</h5>
<button
type="button"
class="btn-close"
@click="closeModal"
aria-label="Close"
></button>
</div>
<!-- Body -->
<div class="modal-body">
<form @submit.prevent="submitForm">
<!-- Fournisseur ID (hidden) -->
<input type="hidden" v-model="formData.fournisseur_id" />
<!-- First Name -->
<div class="mb-3">
<label class="form-label">Prénom</label>
<input
v-model="formData.first_name"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.first_name }"
placeholder="Prénom du contact"
maxlength="191"
/>
<div v-if="errors.first_name" class="invalid-feedback">
{{ errors.first_name }}
</div>
</div>
<!-- Last Name -->
<div class="mb-3">
<label class="form-label">Nom</label>
<input
v-model="formData.last_name"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.last_name }"
placeholder="Nom du contact"
maxlength="191"
/>
<div v-if="errors.last_name" class="invalid-feedback">
{{ errors.last_name }}
</div>
</div>
<!-- Email -->
<div class="mb-3">
<label class="form-label">Email</label>
<input
v-model="formData.email"
type="email"
class="form-control"
:class="{ 'is-invalid': errors.email }"
placeholder="email@example.com"
maxlength="191"
/>
<div v-if="errors.email" class="invalid-feedback">
{{ errors.email }}
</div>
</div>
<!-- Phone -->
<div class="mb-3">
<label class="form-label">Téléphone</label>
<input
v-model="formData.phone"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.phone }"
placeholder="+33 1 23 45 67 89"
maxlength="50"
/>
<div v-if="errors.phone" class="invalid-feedback">
{{ errors.phone }}
</div>
</div>
<!-- Role -->
<div class="mb-3">
<label class="form-label">Rôle</label>
<input
v-model="formData.role"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.role }"
placeholder="Rôle dans l'entreprise"
maxlength="191"
/>
<div v-if="errors.role" class="invalid-feedback">
{{ errors.role }}
</div>
</div>
<!-- General Error -->
<div v-if="errors.general" class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ errors.general }}
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-footer">
<button
type="button"
class="btn btn-outline-secondary"
@click="closeModal"
:disabled="contactIsLoading"
>
<i class="fas fa-times me-1"></i>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
@click="submitForm"
:disabled="contactIsLoading"
>
<i class="fas fa-save me-1"></i>
{{
contactIsLoading
? isModification
? "Modification..."
: "Création..."
: isModification
? "Modifier le contact"
: "Créer le contact"
}}
</button>
</div>
</div>
</div>
</div>
<!-- Backdrop -->
<div
v-if="isVisible"
class="modal-backdrop fade show"
@click="closeModal"
></div>
</template>
<script setup>
import { ref, reactive, watch, onMounted, onUnmounted } from "vue";
import { defineProps, defineEmits } from "vue";
// Props
const props = defineProps({
isVisible: {
type: Boolean,
default: false,
},
fournisseurId: {
type: [Number, String],
required: true,
},
contactIsLoading: {
type: Boolean,
default: false,
},
isModification: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: null,
},
});
// Emits
const emit = defineEmits(["close", "contact-created", "contact-modified"]);
// State
const errors = reactive({});
const formData = reactive({
fournisseur_id: "",
first_name: "",
last_name: "",
email: "",
phone: "",
role: "",
});
// Watch for fournisseurId changes
watch(
() => props.fournisseurId,
(newVal) => {
formData.fournisseur_id = newVal;
},
{ immediate: true }
);
// Watch for contact changes (for modification mode)
watch(
() => props.contact,
(newContact) => {
if (newContact && props.isModification) {
formData.first_name = newContact.first_name || "";
formData.last_name = newContact.last_name || "";
formData.email = newContact.email || "";
formData.phone = newContact.phone || "";
formData.role = newContact.position || "";
}
},
{ immediate: true }
);
// Methods
const closeModal = () => {
resetForm();
emit("close");
};
const resetForm = () => {
Object.keys(formData).forEach((key) => {
if (key !== "fournisseur_id") {
formData[key] = "";
}
});
Object.keys(errors).forEach((key) => delete errors[key]);
};
const validateForm = () => {
// Clear previous errors
Object.keys(errors).forEach((key) => delete errors[key]);
let isValid = true;
// Fournisseur ID validation
if (!formData.fournisseur_id) {
errors.fournisseur_id = "Le fournisseur est obligatoire.";
isValid = false;
}
// First name validation
if (formData.first_name && formData.first_name.length > 191) {
errors.first_name = "Le prénom ne peut pas dépasser 191 caractères.";
isValid = false;
}
// Last name validation
if (formData.last_name && formData.last_name.length > 191) {
errors.last_name = "Le nom ne peut pas dépasser 191 caractères.";
isValid = false;
}
// Email validation
if (formData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
errors.email = "L'adresse email doit être valide.";
isValid = false;
} else if (formData.email.length > 191) {
errors.email = "L'adresse email ne peut pas dépasser 191 caractères.";
isValid = false;
}
}
// Phone validation
if (formData.phone && formData.phone.length > 50) {
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères.";
isValid = false;
}
// Role validation
if (formData.role && formData.role.length > 191) {
errors.role = "Le rôle ne peut pas dépasser 191 caractères.";
isValid = false;
}
// At least one field validation
const hasAtLeastOneField =
formData.first_name ||
formData.last_name ||
formData.email ||
formData.phone;
if (!hasAtLeastOneField) {
errors.general =
"Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
isValid = false;
}
return isValid;
};
const submitForm = async () => {
if (!validateForm()) {
return;
}
try {
// Prepare data for API
const submitData = { ...formData };
// Convert empty strings to null for nullable fields
const nullableFields = [
"first_name",
"last_name",
"email",
"phone",
"role",
];
nullableFields.forEach((field) => {
if (submitData[field] === "") {
submitData[field] = null;
}
});
// Emit event - parent will handle the API call and loading state
if (props.isModification) {
submitData.id = props.contact.id;
emit("contact-modified", submitData);
} else {
emit("contact-created", submitData);
}
// Close modal and reset form
closeModal();
} catch (error) {
console.error("Erreur lors de la création du contact:", error);
// Handle API errors
if (error.response && error.response.data && error.response.data.errors) {
Object.assign(errors, error.response.data.errors);
} else {
errors.general =
"Une erreur est survenue lors de la création du contact.";
}
}
};
// Keyboard event listener for ESC key
const handleKeydown = (event) => {
if (event.key === "Escape" && props.isVisible) {
closeModal();
}
};
// Add event listener when component is mounted
onMounted(() => {
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<style scoped>
.modal {
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
border: none;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.modal-title {
color: #495057;
font-weight: 600;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
border-top: 1px solid #e9ecef;
background-color: #f8f9fa;
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.form-label {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
.form-control {
border: 1px solid #dce1e6;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
transition: all 0.2s ease;
}
.form-control:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
}
.btn-primary {
background-color: #cb0c9f;
border-color: #cb0c9f;
border-radius: 0.375rem;
}
.btn-primary:hover:not(:disabled) {
background-color: #a90982;
border-color: #a90982;
transform: translateY(-1px);
}
.btn-outline-secondary {
border-radius: 0.375rem;
}
.invalid-feedback {
font-size: 0.875rem;
}
.alert {
border: none;
border-radius: 0.375rem;
padding: 0.75rem 1rem;
}
.fade {
transition: opacity 0.15s linear;
}
.modal-backdrop {
opacity: 0.5;
}
</style>

View File

@ -1,21 +1,148 @@
<template>
<div class="card">
<div class="card contact-list-card">
<div class="card-header pb-0">
<h6 class="mb-0">Contacts du fournisseur</h6>
<div class="d-flex align-items-center">
<h6 class="mb-0">Liste des contacts</h6>
<SoftButton
class="btn btn-primary btn-sm ms-auto"
@click="contactModalIsVisible = true"
:disabled="isLoading"
>
<i class="fas fa-plus me-1"></i>Ajouter un contact
</SoftButton>
</div>
<fournisseur-contact-modal
:is-visible="contactModalIsVisible"
:fournisseur-id="fournisseurId"
:contact-is-loading="isLoading"
:is-modification="isModification"
:contact="selectedContact"
@close="closeModal"
@contact-created="handleContactCreated"
@contact-modified="handleContactModified"
/>
</div>
<div class="card-body">
<p class="text-sm">Liste des contacts</p>
<div class="card-body contact-list-body p-0">
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="text-sm text-secondary mt-2">Chargement des contacts...</p>
</div>
<!-- Content State -->
<div v-else-if="contacts.length > 0" class="table-responsive">
<table class="table align-items-center table-flush mb-0">
<thead>
<tr>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7"
>
Contact
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Email
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Téléphone
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Poste
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 text-center"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="contact in contacts" :key="contact.id">
<td>
<div class="d-flex px-2 py-1">
<div class="avatar avatar-sm me-3">
<div
class="avatar-placeholder bg-gradient-info text-white d-flex align-items-center justify-content-center rounded-circle"
>
{{ getInitials(contact.full_name) }}
</div>
</div>
<div class="d-flex flex-column justify-content-center">
<h6 class="mb-0 text-sm">{{ contact.full_name }}</h6>
<p
v-if="contact.is_primary"
class="text-xs text-success mb-0"
>
<i class="fas fa-star me-1"></i>Contact principal
</p>
</div>
</div>
</td>
<td>
<p class="text-xs font-weight-bold mb-0">
{{ contact.email || "-" }}
</p>
</td>
<td>
<p class="text-xs font-weight-bold mb-0">
{{ contact.phone || contact.mobile || "-" }}
</p>
</td>
<td>
<p class="text-xs font-weight-bold mb-0">
{{ contact.position || "-" }}
</p>
</td>
<td class="align-middle text-center">
<div class="d-flex justify-content-center gap-2">
<button
class="btn btn-link text-warning p-0 mb-0"
type="button"
title="Modifier"
@click="handleModifyContact(contact)"
:disabled="isLoading"
>
<i class="fas fa-edit text-sm"></i>
</button>
<button
class="btn btn-link text-danger p-0 mb-0"
type="button"
title="Supprimer"
@click="handleRemoveContact(contact.id)"
:disabled="isLoading"
>
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-else class="text-center py-5">
<i class="fas fa-address-book fa-3x text-secondary opacity-5 mb-3"></i>
<p class="text-sm text-secondary">Aucun contact pour ce fournisseur</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
contacts: {
type: Array,
default: () => [],
},
import { defineProps, defineEmits, ref, computed } from "vue";
import { useContactStore } from "@/stores/contactStore";
import FournisseurContactModal from "./FournisseurContactModal.vue";
import SoftButton from "@/components/SoftButton.vue";
const props = defineProps({
fournisseurId: {
type: [Number, String],
required: true,
@ -25,4 +152,126 @@ defineProps({
default: false,
},
});
// Use contact store directly to get contacts for this fournisseur
const contactStore = useContactStore();
// Computed property to get filtered contacts for this fournisseur
const contacts = computed(() => {
// Filter contacts that belong to this fournisseur and add computed full_name
return contactStore.contacts
.filter((contact) => contact.fournisseur_id === props.fournisseurId)
.map((contact) => ({
...contact,
full_name: `${contact.first_name} ${contact.last_name}`.trim(),
}));
});
const contactModalIsVisible = ref(false);
const isModification = ref(false);
const selectedContact = ref(null);
const emit = defineEmits([
"contact-created",
"contact-modified",
"contact-removed",
]);
const getInitials = (name) => {
if (!name) return "?";
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.substring(0, 2);
};
const handleContactCreated = (newContact) => {
closeModal();
emit("contact-created", newContact);
};
const handleContactModified = (modifiedContact) => {
closeModal();
emit("contact-modified", modifiedContact);
};
const closeModal = () => {
contactModalIsVisible.value = false;
isModification.value = false;
selectedContact.value = null;
};
const handleModifyContact = (contact) => {
selectedContact.value = contact;
isModification.value = true;
contactModalIsVisible.value = true;
};
const handleRemoveContact = async (contactId) => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce contact ?")) {
try {
await contactStore.deleteContact(contactId);
emit("contact-removed", contactId);
} catch (error) {
console.error("Error deleting contact:", error);
// You might want to show an error notification here
}
}
};
</script>
<style scoped>
.contact-list-card {
min-height: 500px;
}
.contact-list-body {
overflow-y: auto;
}
.table-flush {
border-spacing: 0;
border-collapse: collapse;
}
.table-flush thead th {
border-bottom: 1px solid #e9ecef;
padding: 0.75rem 1rem;
}
.table-flush tbody tr {
border-bottom: 1px solid #e9ecef;
}
.table-flush tbody td {
padding: 0.75rem 1rem;
}
.avatar-sm .avatar-placeholder {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
.btn-link:hover {
opacity: 0.8;
}
.gap-2 {
gap: 0.5rem;
}
/* Disabled state for buttons */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Spinner styling */
.spinner-border {
width: 3rem;
height: 3rem;
}
</style>

View File

@ -1,21 +1,548 @@
<template>
<div class="card">
<div class="card-header pb-0">
<h6 class="mb-0">Informations du fournisseur</h6>
<div class="d-flex align-items-center">
<h6 class="mb-0">Informations détaillées</h6>
<button
v-if="!isEditing"
class="btn btn-primary btn-sm ms-auto"
@click="startEdit"
>
<i class="fas fa-edit me-1"></i>Modifier
</button>
<div v-else class="ms-auto">
<button
class="btn btn-outline-secondary btn-sm me-2"
@click="cancelEdit"
>
<i class="fas fa-times me-1"></i>Annuler
</button>
<button
class="btn btn-success btn-sm"
@click="saveChanges"
:disabled="isSaving"
>
<i class="fas fa-save me-1"></i>
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
</button>
</div>
</div>
</div>
<div class="card-body">
<p class="text-sm">Informations détaillées du fournisseur</p>
<p class="text-xs text-secondary">{{ fournisseur.name }}</p>
<form @submit.prevent="saveChanges">
<!-- Informations générales -->
<div class="info-section mb-4">
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
<i class="fas fa-building text-primary me-2"></i>Informations
générales
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label"
>Nom du fournisseur <span class="text-danger">*</span></label
>
<input
v-if="isEditing"
v-model="formData.name"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.name }"
placeholder="Nom du fournisseur"
maxlength="255"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.name }}
</p>
<div v-if="errors.name" class="invalid-feedback d-block">
{{ errors.name }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">SIRET</label>
<input
v-if="isEditing"
v-model="formData.siret"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.siret }"
placeholder="SIRET"
maxlength="20"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.siret || "-" }}
</p>
<div v-if="errors.siret" class="invalid-feedback d-block">
{{ errors.siret }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Numéro de TVA</label>
<input
v-if="isEditing"
v-model="formData.vat_number"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.vat_number }"
placeholder="Numéro de TVA"
maxlength="32"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.vat_number || "-" }}
</p>
<div v-if="errors.vat_number" class="invalid-feedback d-block">
{{ errors.vat_number }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Statut</label>
<div v-if="isEditing" class="form-check form-switch mt-2">
<input
v-model="formData.is_active"
class="form-check-input"
type="checkbox"
:class="{ 'is-invalid': errors.is_active }"
/>
<label class="form-check-label">
{{ formData.is_active ? "Actif" : "Inactif" }}
</label>
</div>
<p v-else class="form-control-static text-sm">
<span
:class="
fournisseur.is_active ? 'text-success' : 'text-danger'
"
>
{{ fournisseur.is_active ? "Actif" : "Inactif" }}
</span>
</p>
<div v-if="errors.is_active" class="invalid-feedback d-block">
{{ errors.is_active }}
</div>
</div>
</div>
</div>
<!-- Contact -->
<div class="info-section mb-4">
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
<i class="fas fa-phone text-success me-2"></i>Contact
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Email</label>
<input
v-if="isEditing"
v-model="formData.email"
type="email"
class="form-control"
:class="{ 'is-invalid': errors.email }"
placeholder="email@example.com"
maxlength="191"
/>
<p v-else class="form-control-static text-sm">
<a
v-if="fournisseur.email"
:href="`mailto:${fournisseur.email}`"
>
{{ fournisseur.email }}
</a>
<span v-else>-</span>
</p>
<div v-if="errors.email" class="invalid-feedback d-block">
{{ errors.email }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone</label>
<input
v-if="isEditing"
v-model="formData.phone"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.phone }"
placeholder="+33 1 23 45 67 89"
maxlength="50"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.phone || "-" }}
</p>
<div v-if="errors.phone" class="invalid-feedback d-block">
{{ errors.phone }}
</div>
</div>
</div>
</div>
<!-- Adresse de facturation -->
<div class="info-section mb-4">
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
<i class="fas fa-map-marker-alt text-warning me-2"></i>Adresse de
facturation
</h6>
<div class="row">
<div class="col-12 mb-3">
<label class="form-label">Adresse ligne 1</label>
<input
v-if="isEditing"
v-model="formData.billing_address_line1"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.billing_address_line1 }"
placeholder="Adresse"
maxlength="255"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.billing_address?.line1 || "-" }}
</p>
<div
v-if="errors.billing_address_line1"
class="invalid-feedback d-block"
>
{{ errors.billing_address_line1 }}
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Adresse ligne 2</label>
<input
v-if="isEditing"
v-model="formData.billing_address_line2"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.billing_address_line2 }"
placeholder="Complément d'adresse"
maxlength="255"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.billing_address?.line2 || "-" }}
</p>
<div
v-if="errors.billing_address_line2"
class="invalid-feedback d-block"
>
{{ errors.billing_address_line2 }}
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Code postal</label>
<input
v-if="isEditing"
v-model="formData.billing_postal_code"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.billing_postal_code }"
placeholder="75001"
maxlength="20"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.billing_address?.postal_code || "-" }}
</p>
<div
v-if="errors.billing_postal_code"
class="invalid-feedback d-block"
>
{{ errors.billing_postal_code }}
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Ville</label>
<input
v-if="isEditing"
v-model="formData.billing_city"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.billing_city }"
placeholder="Paris"
maxlength="191"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.billing_address?.city || "-" }}
</p>
<div v-if="errors.billing_city" class="invalid-feedback d-block">
{{ errors.billing_city }}
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Pays</label>
<input
v-if="isEditing"
v-model="formData.billing_country_code"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.billing_country_code }"
placeholder="FR"
maxlength="2"
/>
<p v-else class="form-control-static text-sm">
{{ fournisseur.billing_address?.country_code || "-" }}
</p>
<div
v-if="errors.billing_country_code"
class="invalid-feedback d-block"
>
{{ errors.billing_country_code }}
</div>
</div>
</div>
</div>
<!-- Notes -->
<div class="info-section">
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
<i class="fas fa-sticky-note text-info me-2"></i>Notes
</h6>
<div class="row">
<div class="col-12 mb-3">
<label class="form-label">Notes internes</label>
<textarea
v-if="isEditing"
v-model="formData.notes"
class="form-control"
:class="{ 'is-invalid': errors.notes }"
rows="4"
placeholder="Notes..."
></textarea>
<p
v-else
class="form-control-static text-sm"
style="white-space: pre-wrap"
>
{{ fournisseur.notes || "-" }}
</p>
<div v-if="errors.notes" class="invalid-feedback d-block">
{{ errors.notes }}
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
import { ref, reactive } from "vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
fournisseur: {
type: Object,
required: true,
},
});
const emit = defineEmits(["fournisseur-updated"]);
const isEditing = ref(false);
const isSaving = ref(false);
const errors = reactive({});
const formData = reactive({
name: "",
vat_number: "",
siret: "",
email: "",
phone: "",
billing_address_line1: "",
billing_address_line2: "",
billing_postal_code: "",
billing_city: "",
billing_country_code: "",
notes: "",
is_active: true,
});
const startEdit = () => {
isEditing.value = true;
Object.assign(formData, {
name: props.fournisseur.name || "",
vat_number: props.fournisseur.vat_number || "",
siret: props.fournisseur.siret || "",
email: props.fournisseur.email || "",
phone: props.fournisseur.phone || "",
billing_address_line1: props.fournisseur.billing_address?.line1 || "",
billing_address_line2: props.fournisseur.billing_address?.line2 || "",
billing_postal_code: props.fournisseur.billing_address?.postal_code || "",
billing_city: props.fournisseur.billing_address?.city || "",
billing_country_code:
props.fournisseur.billing_address?.country_code || "FR",
notes: props.fournisseur.notes || "",
is_active:
props.fournisseur.is_active !== undefined
? props.fournisseur.is_active
: true,
});
Object.keys(errors).forEach((key) => delete errors[key]);
};
const cancelEdit = () => {
isEditing.value = false;
Object.keys(errors).forEach((key) => delete errors[key]);
};
const validateForm = () => {
Object.keys(errors).forEach((key) => delete errors[key]);
let isValid = true;
// Name validation
if (!formData.name || formData.name.trim() === "") {
errors.name = "Le nom du fournisseur est obligatoire.";
isValid = false;
} else if (formData.name.length > 255) {
errors.name = "Le nom du fournisseur ne peut pas dépasser 255 caractères.";
isValid = false;
}
// VAT number validation
if (formData.vat_number && formData.vat_number.length > 32) {
errors.vat_number = "Le numéro de TVA ne peut pas dépasser 32 caractères.";
isValid = false;
}
// SIRET validation
if (formData.siret && formData.siret.length > 20) {
errors.siret = "Le SIRET ne peut pas dépasser 20 caractères.";
isValid = false;
}
// Email validation
if (formData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
errors.email = "L'adresse email doit être valide.";
isValid = false;
} else if (formData.email.length > 191) {
errors.email = "L'adresse email ne peut pas dépasser 191 caractères.";
isValid = false;
}
}
// Phone validation
if (formData.phone && formData.phone.length > 50) {
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères.";
isValid = false;
}
// Billing address validations
if (
formData.billing_address_line1 &&
formData.billing_address_line1.length > 255
) {
errors.billing_address_line1 =
"L'adresse ne peut pas dépasser 255 caractères.";
isValid = false;
}
if (
formData.billing_address_line2 &&
formData.billing_address_line2.length > 255
) {
errors.billing_address_line2 =
"Le complément d'adresse ne peut pas dépasser 255 caractères.";
isValid = false;
}
if (
formData.billing_postal_code &&
formData.billing_postal_code.length > 20
) {
errors.billing_postal_code =
"Le code postal ne peut pas dépasser 20 caractères.";
isValid = false;
}
if (formData.billing_city && formData.billing_city.length > 191) {
errors.billing_city = "La ville ne peut pas dépasser 191 caractères.";
isValid = false;
}
if (formData.billing_country_code) {
if (formData.billing_country_code.length !== 2) {
errors.billing_country_code = "Le code pays doit contenir 2 caractères.";
isValid = false;
}
}
return isValid;
};
const saveChanges = async () => {
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
// Clean up form data
const cleanedData = {};
for (const [key, value] of Object.entries(formData)) {
if (value === "" || value === null || value === undefined) {
cleanedData[key] = null;
} else {
cleanedData[key] = value;
}
}
isEditing.value = false;
emit("fournisseur-updated", cleanedData);
} catch (error) {
console.error("Erreur lors de la mise à jour:", error);
if (error.response && error.response.data && error.response.data.errors) {
Object.assign(errors, error.response.data.errors);
} else {
errors.general = "Une erreur est survenue lors de la sauvegarde.";
}
} finally {
isSaving.value = false;
}
};
</script>
<style scoped>
.form-control-static {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin-bottom: 0;
min-height: calc(1.5em + 1rem);
border: 1px solid transparent;
background-color: transparent;
}
.info-section {
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
border: 1px solid #e9ecef;
}
.form-control:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
}
.invalid-feedback {
font-size: 0.875rem;
}
.form-check-input:checked {
background-color: #cb0c9f;
border-color: #cb0c9f;
}
.text-success {
color: #198754 !important;
}
.text-danger {
color: #dc3545 !important;
}
.btn-primary {
background-color: #cb0c9f;
border-color: #cb0c9f;
}
.btn-primary:hover {
background-color: #a90982;
border-color: #a90982;
}
</style>

View File

@ -56,7 +56,7 @@
title="Contacts récents"
icon="fas fa-address-book text-info"
>
<template v-if="contacts.length > 0">
<template v-if="filteredContacts.length > 0">
<div class="d-flex align-items-center mb-3 justify-content-end">
<button
class="btn btn-sm btn-outline-primary"
@ -66,7 +66,7 @@
</button>
</div>
<div
v-for="contact in contacts.slice(0, 3)"
v-for="contact in filteredContacts.slice(0, 3)"
:key="contact.id"
class="d-flex align-items-center mb-2"
>
@ -109,16 +109,13 @@
<script setup>
import InfoCard from "@/components/atoms/client/InfoCard.vue";
import StatusBadge from "@/components/atoms/client/StatusBadge.vue";
import { defineProps, defineEmits } from "vue";
defineProps({
import { defineProps, defineEmits, computed } from "vue";
import { useContactStore } from "@/stores/contactStore";
const props = defineProps({
fournisseur: {
type: Object,
required: true,
},
contacts: {
type: Array,
default: () => [],
},
formattedAddress: {
type: String,
default: "Aucune adresse renseignée",
@ -129,6 +126,15 @@ defineProps({
},
});
// Use contact store to get filtered contacts for this fournisseur
const contactStore = useContactStore();
const filteredContacts = computed(() => {
return contactStore.contacts.filter(
(contact) => contact.fournisseur_id === props.fournisseurId
);
});
defineEmits(["view-all-contacts"]);
const getInitials = (name) => {

View File

@ -26,19 +26,7 @@
:badge="contactsCount > 0 ? contactsCount : null"
@click="$emit('change-tab', 'contacts')"
/>
<TabNavigationItem
icon="fas fa-map-marker-alt"
label="Adresse"
:is-active="activeTab === 'address'"
@click="$emit('change-tab', 'address')"
/>
<TabNavigationItem
icon="fas fa-map-marked-alt"
label="Localisations"
:is-active="activeTab === 'locations'"
:badge="locationsCount > 0 ? locationsCount : null"
@click="$emit('change-tab', 'locations')"
/>
<TabNavigationItem
icon="fas fa-sticky-note"
label="Notes"

View File

@ -0,0 +1,21 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="multisteps-form mb-5">
<div class="row">
<div class="col-12 col-lg-8 mx-auto my-5">
<slot name="multi-step" />
</div>
</div>
<!--form panels-->
<div class="row">
<div class="col-12 col-lg-8 m-auto">
<slot name="fournisseur-form" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -419,6 +419,11 @@ const routes = [
name: "Gestion fournisseurs",
component: () => import("@/views/pages/Fournisseurs/Fournisseurs.vue"),
},
{
path: "/fournisseurs/new",
name: "Creation fournisseur",
component: () => import("@/views/pages/Fournisseurs/AddFournisseur.vue"),
},
{
path: "/fournisseurs/:id",
name: "Fournisseur details",

View File

@ -10,6 +10,8 @@ export interface Contact {
position: string | null;
notes: string | null;
is_primary: boolean;
fournisseur_id: number | null;
client_id: number | null;
created_at: string;
updated_at: string;
}
@ -221,6 +223,22 @@ export const ContactService = {
return response;
},
async getContactsByFournisseur(
fournisseurId: number,
params?: {
page?: number;
per_page?: number;
}
): Promise<ContactListResponse> {
const response = await request<ContactListResponse>({
url: `/api/fournisseurs/${fournisseurId}/contacts`,
method: "get",
params,
});
return response;
},
};
export default ContactService;
export default ContactService;

View File

@ -0,0 +1,206 @@
import { request } from "./http";
export interface FournisseurAddress {
line1: string | null;
line2: string | null;
postal_code: string | null;
city: string | null;
country_code: string | null;
full_address?: string;
}
export interface Fournisseur {
id: number;
name: string;
vat_number: string | null;
siret: string | null;
email: string | null;
phone: string | null;
billing_address: FournisseurAddress;
notes: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
commercial?: string;
}
export interface FournisseurListResponse {
data: Fournisseur[];
meta?: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
}
export interface FournisseurResponse {
data: Fournisseur;
}
export interface CreateFournisseurPayload {
name: string;
vat_number?: string | null;
siret?: string | null;
email?: string | null;
phone?: string | null;
billing_address_line1?: string | null;
billing_address_line2?: string | null;
billing_postal_code?: string | null;
billing_city?: string | null;
billing_country_code?: string | null;
notes?: string | null;
is_active?: boolean;
}
export interface UpdateFournisseurPayload
extends Partial<CreateFournisseurPayload> {
id: number;
}
export const FournisseurService = {
/**
* Get all fournisseurs with pagination
*/
async getAllFournisseurs(params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
}): Promise<FournisseurListResponse> {
const response = await request<FournisseurListResponse>({
url: "/api/fournisseurs",
method: "get",
params,
});
return response;
},
/**
* Get a specific fournisseur by ID
*/
async getFournisseur(id: number): Promise<FournisseurResponse> {
const response = await request<FournisseurResponse>({
url: `/api/fournisseurs/${id}`,
method: "get",
});
return response;
},
/**
* Create a new fournisseur
*/
async createFournisseur(
payload: CreateFournisseurPayload
): Promise<FournisseurResponse> {
const formattedPayload = this.transformFournisseurPayload(payload);
const response = await request<FournisseurResponse>({
url: "/api/fournisseurs",
method: "post",
data: formattedPayload,
});
return response;
},
/**
* Update an existing fournisseur
*/
async updateFournisseur(
payload: UpdateFournisseurPayload
): Promise<FournisseurResponse> {
const { id, ...updateData } = payload;
const formattedPayload = this.transformFournisseurPayload(updateData);
const response = await request<FournisseurResponse>({
url: `/api/fournisseurs/${id}`,
method: "put",
data: formattedPayload,
});
return response;
},
/**
* Delete a fournisseur
*/
async deleteFournisseur(
id: number
): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: `/api/fournisseurs/${id}`,
method: "delete",
});
return response;
},
/**
* Transform fournisseur payload to match Laravel form request structure
*/
transformFournisseurPayload(payload: Partial<CreateFournisseurPayload>): any {
const transformed: any = { ...payload };
// Ensure boolean values are properly formatted
if (typeof transformed.is_active === "boolean") {
transformed.is_active = transformed.is_active ? 1 : 0;
}
// Remove undefined values to avoid sending them
Object.keys(transformed).forEach((key) => {
if (transformed[key] === undefined) {
delete transformed[key];
}
});
return transformed;
},
/**
* Search fournisseurs by name
*/
async searchFournisseurs(
query: string,
params?: {
exact_match?: boolean;
}
): Promise<Fournisseur[]> {
const response = await request<{
data: Fournisseur[];
count: number;
message: string;
}>({
url: "/api/fournisseurs/searchBy",
method: "get",
params: {
name: query,
exact_match: params?.exact_match || false,
},
});
return response.data;
},
/**
* Get active fournisseurs only
*/
async getActiveFournisseurs(params?: {
page?: number;
per_page?: number;
}): Promise<FournisseurListResponse> {
const response = await request<FournisseurListResponse>({
url: "/api/fournisseurs",
method: "get",
params: {
is_active: true,
...params,
},
});
return response;
},
};
export default FournisseurService;

View File

@ -359,6 +359,38 @@ export const useContactStore = defineStore("contact", () => {
}
};
const fetchContactsByFournisseur = async (
fournisseurId: number,
params?: {
page?: number;
per_page?: number;
}
) => {
setLoading(true);
setError(null);
try {
const response = await ContactService.getContactsByFournisseur(
fournisseurId,
params
);
setContacts(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des contacts du client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get client list contact
*/
@ -434,6 +466,7 @@ export const useContactStore = defineStore("contact", () => {
fetchContactsByClient,
resetState,
getClientListContact,
fetchContactsByFournisseur,
};
});

View File

@ -0,0 +1,308 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import FournisseurService from "@/services/fournisseur";
import type {
Fournisseur,
CreateFournisseurPayload,
UpdateFournisseurPayload,
FournisseurListResponse,
} from "@/services/fournisseur";
export const useFournisseurStore = defineStore("fournisseur", () => {
// State
const fournisseurs = ref<Fournisseur[]>([]);
const currentFournisseur = ref<Fournisseur | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const searchResults = ref<Fournisseur[]>([]);
// Pagination state
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
});
// Getters
const allFournisseurs = computed(() => fournisseurs.value);
const activeFournisseurs = computed(() =>
fournisseurs.value.filter((fournisseur) => fournisseur.is_active)
);
const inactiveFournisseurs = computed(() =>
fournisseurs.value.filter((fournisseur) => !fournisseur.is_active)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getFournisseurById = computed(() => (id: number) =>
fournisseurs.value.find((fournisseur) => fournisseur.id === id)
);
const getPagination = computed(() => pagination.value);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setFournisseurs = (newFournisseurs: Fournisseur[]) => {
fournisseurs.value = newFournisseurs;
};
const setCurrentFournisseur = (fournisseur: Fournisseur | null) => {
currentFournisseur.value = fournisseur;
};
const setSearchFournisseur = (searchFournisseur: Fournisseur[]) => {
searchResults.value = searchFournisseur;
};
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 fournisseurs with optional pagination and filters
*/
const fetchFournisseurs = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
}) => {
setLoading(true);
setError(null);
try {
const response = await FournisseurService.getAllFournisseurs(params);
setFournisseurs(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch fournisseurs";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch a single fournisseur by ID
*/
const fetchFournisseur = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await FournisseurService.getFournisseur(id);
setCurrentFournisseur(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch fournisseur";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Create a new fournisseur
*/
const createFournisseur = async (payload: CreateFournisseurPayload) => {
setLoading(true);
setError(null);
try {
const response = await FournisseurService.createFournisseur(payload);
// Add the new fournisseur to the list
fournisseurs.value.push(response.data);
setCurrentFournisseur(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to create fournisseur";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Update an existing fournisseur
*/
const updateFournisseur = async (payload: UpdateFournisseurPayload) => {
setLoading(true);
setError(null);
try {
const response = await FournisseurService.updateFournisseur(payload);
const updatedFournisseur = response.data;
// Update in the fournisseurs list
const index = fournisseurs.value.findIndex(
(fournisseur) => fournisseur.id === updatedFournisseur.id
);
if (index !== -1) {
fournisseurs.value[index] = updatedFournisseur;
}
// Update current fournisseur if it's the one being edited
if (
currentFournisseur.value &&
currentFournisseur.value.id === updatedFournisseur.id
) {
setCurrentFournisseur(updatedFournisseur);
}
return updatedFournisseur;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to update fournisseur";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Delete a fournisseur
*/
const deleteFournisseur = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await FournisseurService.deleteFournisseur(id);
// Remove from the fournisseurs list
fournisseurs.value = fournisseurs.value.filter(
(fournisseur) => fournisseur.id !== id
);
// Clear current fournisseur if it's the one being deleted
if (currentFournisseur.value && currentFournisseur.value.id === id) {
setCurrentFournisseur(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to delete fournisseur";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Search fournisseurs
*/
const searchFournisseurs = async (
query: string,
exactMatch: boolean = false
) => {
setLoading(true);
error.value = null;
try {
const results = await FournisseurService.searchFournisseurs(query, {
exact_match: exactMatch,
});
setSearchFournisseur(results);
return results;
} catch (err) {
error.value = "Erreur lors de la recherche des fournisseurs";
console.error("Error searching fournisseurs:", err);
setSearchFournisseur([]);
throw err;
} finally {
setLoading(false);
}
};
/**
* Clear current fournisseur
*/
const clearCurrentFournisseur = () => {
setCurrentFournisseur(null);
};
/**
* Clear all state
*/
const clearStore = () => {
fournisseurs.value = [];
currentFournisseur.value = null;
error.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
};
};
return {
// State
fournisseurs,
currentFournisseur,
loading,
error,
// Getters
allFournisseurs,
activeFournisseurs,
inactiveFournisseurs,
isLoading,
hasError,
getError,
getFournisseurById,
getPagination,
// Actions
fetchFournisseurs,
fetchFournisseur,
createFournisseur,
updateFournisseur,
deleteFournisseur,
searchFournisseurs,
clearCurrentFournisseur,
clearStore,
clearError,
};
});

View File

@ -11,12 +11,14 @@
import AddClientPresentation from "@/components/Organism/CRM/AddClientPresentation.vue";
import { useClientCategoryStore } from "@/stores/clientCategorie.store";
import { useClientStore } from "@/stores/clientStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const categoryClientStore = useClientCategoryStore();
const clientStore = useClientStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
@ -33,12 +35,13 @@ const handleCreateClient = async (form) => {
// Call the store to create client
const client = await clientStore.createClient(form);
// Show success message
// Show success notification
notificationStore.created("Client");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Clients" });
router.push({ name: "Gestion clients" });
}, 2000);
} catch (error) {
console.error("Error creating client:", error);
@ -46,13 +49,17 @@ const handleCreateClient = async (form) => {
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
alert(errorMessage);
notificationStore.error("Erreur", errorMessage);
} else {
alert("Une erreur inattendue s'est produite");
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};

View File

@ -9,18 +9,38 @@
import AddContactPresentation from "@/components/Organism/CRM/contact/AddContactPresentation.vue";
import useContactStore from "@/stores/contactStore";
import { useClientStore } from "@/stores/clientStore";
import { useNotificationStore } from "@/stores/notification";
import { ref } from "vue";
import { useRouter } from "vue-router";
const contactStore = useContactStore();
const clientStore = useClientStore();
const notificationStore = useNotificationStore();
const router = useRouter();
const resultSeach = ref([]);
const handleCreateContact = async (contactData) => {
await contactStore.createContact(contactData);
try {
await contactStore.createContact(contactData);
notificationStore.created("Contact");
// Redirect after success
setTimeout(() => {
router.push({ name: "Gestion contacts" });
}, 1500);
} catch (error) {
console.error("Error creating contact:", error);
notificationStore.error("Erreur", "Impossible de créer le contact");
}
};
const handleSearchClient = async (searchInput) => {
resultSeach.value = await clientStore.searchClients(searchInput);
try {
resultSeach.value = await clientStore.searchClients(searchInput);
} catch (error) {
console.error("Error searching clients:", error);
notificationStore.error("Erreur", "Impossible de rechercher les clients");
}
};
</script>

View File

@ -25,12 +25,14 @@ import { useRoute } from "vue-router";
import { useClientStore } from "@/stores/clientStore";
import { useContactStore } from "@/stores/contactStore";
import { useClientLocationStore } from "@/stores/clientLocation";
import { useNotificationStore } from "@/stores/notification";
import ClientDetailPresentation from "@/components/Organism/CRM/ClientDetailPresentation.vue";
const route = useRoute();
const clientStore = useClientStore();
const contactStore = useContactStore();
const clientLocationStore = useClientLocationStore();
const notificationStore = useNotificationStore();
// Ensure client_id is a number
const client_id = Number(route.params.id);
@ -54,15 +56,22 @@ onMounted(async () => {
const updateClient = async (data) => {
if (!client_id) {
console.error("Missing client id");
notificationStore.error("Erreur", "ID du client manquant");
return;
}
// If data is FormData (e.g. file upload), append the id instead of spreading
if (data instanceof FormData) {
data.set("id", String(client_id));
await clientStore.updateClient(data);
} else {
await clientStore.updateClient({ id: client_id, ...data });
try {
// If data is FormData (e.g. file upload), append the id instead of spreading
if (data instanceof FormData) {
data.set("id", String(client_id));
await clientStore.updateClient(data);
} else {
await clientStore.updateClient({ id: client_id, ...data });
}
notificationStore.updated("Client");
} catch (error) {
console.error("Error updating client:", error);
notificationStore.error("Erreur", "Impossible de mettre à jour le client");
}
};
@ -71,8 +80,10 @@ const createNewContact = async (data) => {
await contactStore.createContact(data);
// Refresh contacts list after creation
contacts_client.value = await contactStore.getClientListContact(client_id);
notificationStore.created("Contact");
} catch (error) {
console.error("Error creating contact:", error);
notificationStore.error("Erreur", "Impossible de créer le contact");
}
};
@ -80,8 +91,10 @@ const updateContact = async (modifiedContact) => {
try {
await contactStore.updateContact(modifiedContact);
contacts_client.value = await contactStore.getClientListContact(client_id);
notificationStore.updated("Contact");
} catch (error) {
console.error("Error updating contact:", error);
notificationStore.error("Erreur", "Impossible de modifier le contact");
}
};
@ -91,8 +104,10 @@ const createNewLocation = async (data) => {
// Refresh locations list after creation
const response = await clientLocationStore.getClientLocations(client_id);
locations_client.value = response || [];
notificationStore.created("Localisation");
} catch (error) {
console.error("Error creating location:", error);
notificationStore.error("Erreur", "Impossible de créer la localisation");
}
};
@ -102,8 +117,10 @@ const modifyLocation = async (location) => {
// Refresh locations list after modification
const response = await clientLocationStore.getClientLocations(client_id);
locations_client.value = response || [];
notificationStore.updated("Localisation");
} catch (error) {
console.error("Error modifying location:", error);
notificationStore.error("Erreur", "Impossible de modifier la localisation");
}
};
@ -113,8 +130,13 @@ const removeLocation = async (locationId) => {
// Refresh locations list after deletion
const response = await clientLocationStore.getClientLocations(client_id);
locations_client.value = response || [];
notificationStore.deleted("Localisation");
} catch (error) {
console.error("Error removing location:", error);
notificationStore.error(
"Erreur",
"Impossible de supprimer la localisation"
);
}
};
</script>

View File

@ -0,0 +1,59 @@
<template>
<add-fournisseur-presentation
:loading="fournisseurStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-fournisseur="handleCreateFournisseur"
/>
</template>
<script setup>
import AddFournisseurPresentation from "@/components/Organism/CRM/AddFournisseurPresentation.vue";
import { useFournisseurStore } from "@/stores/fournisseurStore";
import { useNotificationStore } from "@/stores/notification";
import { ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const fournisseurStore = useFournisseurStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
const handleCreateFournisseur = async (form) => {
try {
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create fournisseur
const fournisseur = await fournisseurStore.createFournisseur(form);
// Show success notification
notificationStore.created("Fournisseur");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Gestion fournisseurs" });
}, 2000);
} catch (error) {
console.error("Error creating fournisseur:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -1,18 +1,18 @@
<template>
<fournisseur-detail-presentation
v-if="currentFournisseur"
:fournisseur="currentFournisseur"
:contacts="contacts_fournisseur"
v-if="fournisseurStore.currentFournisseur"
:fournisseur="fournisseurStore.currentFournisseur"
:locations="locations_fournisseur"
:is-loading="isLoading"
:is-loading="fournisseurStore.isLoading"
:fournisseur-avatar="fournisseurAvatar"
:active-tab="activeTab"
:file-input="fileInput"
:contact-loading="contactLoading"
:contact-loading="contactStore.isLoading"
:location-loading="locationLoading"
@update-the-fournisseur="updateFournisseur"
@add-new-contact="createNewContact"
@updating-contact="updateContact"
@contact-removed="handleContactRemoved"
@add-new-location="createNewLocation"
@modify-location="modifyLocation"
@remove-location="removeLocation"
@ -22,126 +22,119 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { useFournisseurStore } from "@/stores/fournisseurStore";
import { useNotificationStore } from "@/stores/notification";
import { useContactStore } from "@/stores/contactStore";
import FournisseurDetailPresentation from "@/components/Organism/CRM/FournisseurDetailPresentation.vue";
const route = useRoute();
const fournisseurStore = useFournisseurStore();
const notificationStore = useNotificationStore();
const contactStore = useContactStore();
// Ensure fournisseur_id is a number
const fournisseur_id = Number(route.params.id);
const contacts_fournisseur = ref([]);
const locations_fournisseur = ref([]);
const activeTab = ref("overview");
const fournisseurAvatar = ref(null);
const fileInput = ref(null);
const isLoading = ref(false);
const contactLoading = ref(false);
const locationLoading = ref(false);
// Dummy fournisseur data
const currentFournisseur = ref({
id: fournisseur_id,
name: "Fournisseur Alpha",
commercial: "Jean Dupont",
billing_address: {
line1: "123 Rue de la Paix",
line2: "Bâtiment A",
postal_code: "75001",
city: "Paris",
country_code: "FR",
},
type_label: "Entreprise",
email: "contact@alpha.fr",
phone: "+33 1 23 45 67 89",
is_active: true,
siret: "12345678901234",
tva_number: "FR12345678901",
payment_terms: "30 jours",
notes: "Fournisseur principal pour les matériaux de construction",
});
onMounted(async () => {
// Dummy contacts data
contacts_fournisseur.value = [
{
id: 1,
first_name: "Pierre",
last_name: "Martin",
email: "pierre.martin@alpha.fr",
phone: "+33 1 23 45 67 90",
position: "Responsable commercial",
is_primary: true,
},
{
id: 2,
first_name: "Sophie",
last_name: "Dubois",
email: "sophie.dubois@alpha.fr",
phone: "+33 1 23 45 67 91",
position: "Assistante",
is_primary: false,
},
];
if (fournisseur_id) {
// Load contacts for this fournisseur
await contactStore.fetchContactsByFournisseur(fournisseur_id);
await fournisseurStore.fetchFournisseur(fournisseur_id);
// Dummy locations data
locations_fournisseur.value = [
{
id: 1,
name: "Siège social",
address: {
line1: "123 Rue de la Paix",
line2: "Bâtiment A",
postal_code: "75001",
city: "Paris",
country_code: "FR",
},
gps_lat: "48.8566",
gps_lng: "2.3522",
is_default: true,
},
{
id: 2,
name: "Entrepôt",
address: {
line1: "456 Avenue des Champs",
line2: null,
postal_code: "93100",
city: "Montreuil",
country_code: "FR",
},
gps_lat: "48.8634",
gps_lng: "2.4411",
is_default: false,
},
];
// TODO: Fetch locations when API is ready
// locations_fournisseur.value = await locationStore.getFournisseurLocations(fournisseur_id);
}
});
const updateFournisseur = async (data) => {
console.log("Update fournisseur:", data);
// TODO: Implement update logic with store
if (!fournisseur_id) {
console.error("Missing fournisseur id");
notificationStore.error("Erreur", "ID du fournisseur manquant");
return;
}
try {
await fournisseurStore.updateFournisseur({ id: fournisseur_id, ...data });
notificationStore.updated("Fournisseur");
} catch (error) {
console.error("Error updating fournisseur:", error);
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le fournisseur"
);
}
};
const createNewContact = async (data) => {
console.log("Create new contact:", data);
// TODO: Implement create contact logic
try {
// Ensure fournisseur_id is set
const contactData = {
...data,
fournisseur_id: fournisseur_id,
client_id: null, // Explicitly set to null for fournisseur contacts
};
await contactStore.createContact(contactData);
notificationStore.created("Contact");
} catch (error) {
console.error("Error creating contact:", error);
notificationStore.error("Erreur", "Impossible de créer le contact");
}
};
const updateContact = async (modifiedContact) => {
console.log("Update contact:", modifiedContact);
// TODO: Implement update contact logic
try {
await contactStore.updateContact(modifiedContact);
notificationStore.updated("Contact");
} catch (error) {
console.error("Error updating contact:", error);
notificationStore.error("Erreur", "Impossible de modifier le contact");
}
};
const handleContactRemoved = (contactId) => {
// The contact is already removed from the store
// No additional action needed as the display will update automatically
notificationStore.deleted("Contact");
};
const createNewLocation = async (data) => {
console.log("Create new location:", data);
// TODO: Implement create location logic
try {
console.log("Create new location:", data);
// TODO: Implement with location store when ready
notificationStore.created("Localisation");
} catch (error) {
notificationStore.error("Erreur", "Impossible de créer la localisation");
}
};
const modifyLocation = async (location) => {
console.log("Modify location:", location);
// TODO: Implement modify location logic
try {
console.log("Modify location:", location);
// TODO: Implement with location store when ready
notificationStore.updated("Localisation");
} catch (error) {
notificationStore.error("Erreur", "Impossible de modifier la localisation");
}
};
const removeLocation = async (locationId) => {
console.log("Remove location:", locationId);
// TODO: Implement remove location logic
try {
console.log("Remove location:", locationId);
// TODO: Implement with location store when ready
notificationStore.deleted("Localisation");
} catch (error) {
notificationStore.error(
"Erreur",
"Impossible de supprimer la localisation"
);
}
};
</script>

View File

@ -1,104 +1,29 @@
<template>
<fournisseur-presentation
:fournisseur-data="fournisseurs"
:loading-data="loading"
:fournisseur-data="fournisseurStore.fournisseurs"
:loading-data="fournisseurStore.loading"
@push-details="goDetails"
/>
</template>
<script setup>
import FournisseurPresentation from "@/components/Organism/CRM/FournisseurPresentation.vue";
import { ref } from "vue";
import { useFournisseurStore } from "@/stores/fournisseurStore";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const loading = ref(false);
const fournisseurStore = useFournisseurStore();
// Dummy data for fournisseurs
const fournisseurs = ref([
{
id: 1,
name: "Fournisseur Alpha",
commercial: "Jean Dupont",
billing_address: {
line1: "123 Rue de la Paix",
postal_code: "75001",
city: "Paris",
country_code: "FR",
},
type_label: "Entreprise",
email: "contact@alpha.fr",
phone: "+33 1 23 45 67 89",
is_active: true,
},
{
id: 2,
name: "Fournisseur Beta",
commercial: "Marie Martin",
billing_address: {
line1: "456 Avenue des Champs",
postal_code: "69001",
city: "Lyon",
country_code: "FR",
},
type_label: "Entreprise",
email: "info@beta.fr",
phone: "+33 4 78 90 12 34",
is_active: true,
},
{
id: 3,
name: "Fournisseur Gamma",
commercial: "Pierre Dubois",
billing_address: {
line1: "789 Boulevard Victor Hugo",
postal_code: "13001",
city: "Marseille",
country_code: "FR",
},
type_label: "Association",
email: "contact@gamma.org",
phone: "+33 4 91 23 45 67",
is_active: false,
},
{
id: 4,
name: "Fournisseur Delta",
commercial: "Sophie Laurent",
billing_address: {
line1: "321 Rue de la République",
postal_code: "33000",
city: "Bordeaux",
country_code: "FR",
},
type_label: "Entreprise",
email: "hello@delta.fr",
phone: "+33 5 56 78 90 12",
is_active: true,
},
{
id: 5,
name: "Fournisseur Epsilon",
commercial: "Luc Bernard",
billing_address: {
line1: "654 Avenue de la Liberté",
postal_code: "59000",
city: "Lille",
country_code: "FR",
},
type_label: "Particulier",
email: "contact@epsilon.fr",
phone: "+33 3 20 12 34 56",
is_active: true,
},
]);
onMounted(async () => {
await fournisseurStore.fetchFournisseurs();
});
const goDetails = (id) => {
console.log("Navigate to fournisseur details:", id);
// router.push({
// name: "Fournisseur details",
// params: {
// id: id,
// },
// });
router.push({
name: "Fournisseur details",
params: {
id: id,
},
});
};
</script>