contact CRUD

This commit is contained in:
Nyavokevin 2025-10-16 17:29:31 +03:00
parent c5a4fcc546
commit 98420a29b5
24 changed files with 1718 additions and 62 deletions

View File

@ -111,6 +111,41 @@ class ClientController extends Controller
}
}
public function searchBy(Request $request): JsonResponse
{
try {
$name = $request->get('name', '');
if (empty($name)) {
return response()->json([
'message' => 'Le paramètre "name" est requis.',
], 400);
}
$clients = $this->clientRepository->searchByName($name);
return response()->json([
'data' => $clients,
'count' => $clients->count(),
'message' => $clients->count() > 0
? 'Clients trouvés avec succès.'
: 'Aucun client trouvé.',
], 200);
} catch (\Exception $e) {
Log::error('Error searching clients by name: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'search_term' => $name,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche des clients.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified client.
*/

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreContactRequest;
use App\Http\Requests\UpdateContactRequest;
use App\Http\Resources\Contact\ContactResource;
use App\Http\Resources\Contact\ContactCollection;
use App\Repositories\ContactRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -23,11 +24,21 @@ class ContactController extends Controller
/**
* Display a listing of contacts.
*/
public function index(): AnonymousResourceCollection|JsonResponse
public function index(): ContactCollection
{
try {
$contacts = $this->contactRepository->all();
return ContactResource::collection($contacts);
$perPage = request('per_page', 15);
$filters = [
'search' => request('search'),
'is_primary' => request('is_primary'),
'client_id' => request('client_id'),
'sort_by' => request('sort_by', 'created_at'),
'sort_direction' => request('sort_direction', 'desc'),
];
$contacts = $this->contactRepository->paginate($perPage, $filters);
return new ContactCollection($contacts);
} catch (\Exception $e) {
Log::error('Error fetching contacts: ' . $e->getMessage(), [
'exception' => $e,

View File

@ -15,7 +15,7 @@ class ContactCollection extends ResourceCollection
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'data' => ContactResource::collection($this->collection),
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),

View File

@ -2,6 +2,7 @@
namespace App\Http\Resources\Contact;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@ -25,9 +26,12 @@ class ContactResource extends JsonResource
'role' => $this->role,
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'client' => new ClientResource($this->whenLoaded('client')),
'client' => $this->whenLoaded('client', [
'id' => $this->client->id,
'name' => $this->client->name,
]),
];
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Contact extends Model
{
@ -15,9 +16,23 @@ class Contact extends Model
'position',
'notes',
'is_primary',
'client_id'
];
protected $casts = [
'is_primary' => 'boolean',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/**
* Get the contact's full name.
*/
public function getFullNameAttribute(): string
{
return trim("{$this->first_name} {$this->last_name}");
}
}

View File

@ -52,4 +52,18 @@ class ClientRepository extends BaseRepository implements ClientRepositoryInterfa
return $query->paginate($perPage);
}
public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false)
{
$query = $this->model->newQuery();
if ($exactMatch) {
$query->where('name', $name);
} else {
$query->where('name', 'like', '%' . $name . '%');
}
return $query->get();
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repositories;
use App\Models\Contact;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ContactRepository extends BaseRepository implements ContactRepositoryInterface
{
@ -12,4 +13,46 @@ class ContactRepository extends BaseRepository implements ContactRepositoryInter
{
parent::__construct($model);
}
/**
* Get paginated contacts
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery()->with('client');
// Apply filters
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('first_name', 'like', '%' . $filters['search'] . '%')
->orWhere('last_name', 'like', '%' . $filters['search'] . '%')
->orWhere('email', 'like', '%' . $filters['search'] . '%')
->orWhere('phone', 'like', '%' . $filters['search'] . '%')
->orWhere('mobile', 'like', '%' . $filters['search'] . '%')
->orWhere('position', 'like', '%' . $filters['search'] . '%');
});
}
if (isset($filters['is_primary'])) {
$query->where('is_primary', $filters['is_primary']);
}
if (!empty($filters['client_id'])) {
$query->where('client_id', $filters['client_id']);
}
// Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
// Special handling for name sorting
if ($sortField === 'name') {
$query->orderBy('last_name', $sortDirection)
->orderBy('first_name', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
}
return $query->paginate($perPage);
}
}

View File

@ -6,5 +6,5 @@ namespace App\Repositories;
interface ContactRepositoryInterface extends BaseRepositoryInterface
{
// Add Contact-specific methods here later if needed
function paginate(int $perPage = 15, array $filters = []);
}

View File

@ -34,6 +34,9 @@ Route::prefix('auth')->group(function () {
// Protected API routes
Route::middleware('auth:sanctum')->group(function () {
// Client management
// IMPORTANT: Specific routes must come before apiResource
Route::get('/clients/searchBy', [ClientController::class, 'searchBy']);
Route::apiResource('clients', ClientController::class);
Route::apiResource('client-groups', ClientGroupController::class);
Route::apiResource('client-locations', ClientLocationController::class);

View File

@ -0,0 +1,120 @@
# ContactForm Client Selection - Simplified
## What Changed
The client selection has been simplified to **only store the client_id** without modifying any form fields.
## Previous Behavior ❌
- Selecting a client pre-filled form fields (first_name, last_name, email, phone, mobile)
- This was confusing because you're creating a new contact, not editing client data
## New Behavior ✅
- Selecting a client **only stores the client_id**
- Form fields remain empty for you to fill in the contact's information
- Shows "Client sélectionné" banner with client name and email
- Parent component receives the client_id via `clientSelected` event
## How It Works
### 1. Select Client
```vue
<button @mousedown="selectClient(client)">
<strong>{{ client.name }}</strong>
</button>
```
### 2. Store Client Reference
```javascript
const selectClient = (client) => {
// Store the selected client (to show which client is selected)
selectedContact.value = client;
// Clear search
searchQuery.value = "";
showDropdown.value = false;
// Emit client selected event with client ID
emit("clientSelected", client.id);
};
```
### 3. Display Selected Client
Shows a green banner:
```
✓ Client sélectionné
[Client Name] • [client@email.com]
Le contact sera lié à ce client
[Changer button]
```
### 4. Parent Handles Submission
The parent component should listen for `clientSelected` and add `client_id` to the form data:
```javascript
const selectedClientId = ref(null);
const handleClientSelected = (clientId) => {
selectedClientId.value = clientId;
};
const handleCreateContact = (formData) => {
// Add client_id to the form data before submitting
const payload = {
...formData,
client_id: selectedClientId.value
};
// Submit to API
await api.post('/contacts', payload);
};
```
## Events Emitted
- `clientSelected(clientId)` - When a client is selected or cleared
- `clientId`: The ID of the selected client, or `null` if cleared
- `createContact(formData)` - When the form is submitted
- Parent should add `client_id` to formData before API call
## Clear Selection
Clicking "Changer" button:
- Clears the selected client
- Emits `clientSelected(null)`
- Does NOT clear form fields (user can keep typing)
## Usage Example
```vue
<ContactForm
:searchResults="clientSearchResults"
@searchClient="searchClients"
@clientSelected="handleClientSelected"
@createContact="handleCreateContact"
/>
```
```javascript
const selectedClientId = ref(null);
const handleClientSelected = (clientId) => {
selectedClientId.value = clientId;
};
const handleCreateContact = async (formData) => {
try {
const payload = {
...formData,
client_id: selectedClientId.value // Add the selected client ID
};
await contactApi.create(payload);
} catch (error) {
console.error(error);
}
};
```
## File Modified
- `src/components/molecules/form/ContactForm.vue`

View File

@ -0,0 +1,95 @@
# ContactForm Client Dropdown Fix
## Problem
The client dropdown in ContactForm was showing "Aucun client trouvé" even when the API returned results. The component was trying to iterate over `client` in the template but reference it as `contact` which didn't exist.
## Issues Found
### 1. Variable Name Mismatch (Line 51, 57)
```vue
<!-- WRONG -->
<button v-for="client in props.searchResults" @mousedown="selectContact(contact)">
<strong>{{ contact.name }}</strong>
</button>
<!-- FIXED -->
<button v-for="client in props.searchResults" @mousedown="selectClient(client)">
<strong>{{ client.name }}</strong>
</button>
```
**Why it failed**: The loop variable was `client` but the template tried to access `contact`, which was undefined, causing nothing to render.
### 2. Missing Method
The component was calling `selectClient()` but only had `selectContact()` method defined.
### 3. Event Emission Mismatch
The component was emitting `searchClient` in the code but the parent might be listening for `searchContact`.
## Changes Made
### 1. Fixed Template Variable References
- Changed `@mousedown="selectContact(contact)"``@mousedown="selectClient(client)"`
- Changed `{{ contact.name }}``{{ client.name }}`
- Added client email display in dropdown
### 2. Added `selectClient()` Method
```javascript
const selectClient = (client) => {
selectedContact.value = client;
// Split name into first/last if needed
if (client.name) {
const nameParts = client.name.split(' ');
if (nameParts.length > 1) {
form.value.first_name = nameParts[0];
form.value.last_name = nameParts.slice(1).join(' ');
} else {
form.value.first_name = client.name;
}
}
form.value.email = client.email || "";
form.value.phone = client.phone || "";
form.value.mobile = client.mobile || "";
searchQuery.value = "";
showDropdown.value = false;
emit("clientSelected", client.id);
};
```
### 3. Updated Emits Declaration
Added `searchClient` and `clientSelected` to the emits array:
```javascript
const emit = defineEmits([
"createContact",
"searchClient", // For searching clients
"clientSelected", // When a client is selected
"contactSelected" // When a contact is selected
]);
```
## How It Works Now
1. User types in search input → emits `searchClient` event
2. Parent component makes API call to `/api/clients/searchBy?name=...`
3. Results are passed back via `searchResults` prop
4. Dropdown shows clients with name and email
5. User clicks a client → `selectClient()` is called
6. Form is pre-filled with client data (name split into first/last, email, phone)
7. Parent receives `clientSelected` event with client ID
## Testing
To verify the fix works:
1. Start typing a client name in the search field
2. The dropdown should now display the matching clients
3. Click on a client
4. The form should pre-fill with the client's information
5. The contact can then be created and linked to that client
## File Modified
- `src/components/molecules/form/ContactForm.vue`

View File

@ -0,0 +1,181 @@
# ContactTable Component - Complete Redesign
## Overview
The ContactTable has been completely rewritten to match the ClientTable structure with improved features, loading states, and action buttons.
## New Features Added
### 1. **Loading State with Skeleton Screen**
- Shows animated skeleton rows while data is loading
- Loading spinner in top-right corner
- Smooth pulse animation for better UX
### 2. **Complete Data Display**
Columns now show:
- **Contact Name** - With avatar and full name
- **Client** - Shows associated client name (or "-" if none)
- **Email** - Contact email address
- **Phone / Mobile** - Both phone numbers with icons
- **Position** - Job title/position
- **Status** - Shows star badge if primary contact
- **Actions** - View, Edit, Delete buttons
### 3. **Action Buttons**
Three action buttons per contact:
- **View** (Info/Blue) - View contact details
- **Edit** (Warning/Yellow) - Edit contact information
- **Delete** (Danger/Red) - Delete contact
### 4. **Empty State**
Shows a friendly message when no contacts exist:
- Address book icon
- "Aucun contact trouvé" message
- Helpful text
### 5. **DataTable Integration**
- Searchable table
- Pagination (5, 10, 15, 20 per page)
- Fixed height scrolling
- Automatic reinitialization on data changes
### 6. **Event Emissions**
The component now emits three events:
```javascript
emit("view", contactId) // When view button clicked
emit("edit", contactId) // When edit button clicked
emit("delete", contactId) // When delete button clicked
```
## Usage
### Basic Usage
```vue
<ContactTable
:data="contacts"
:loading="isLoading"
:skeletonRows="5"
@view="handleViewContact"
@edit="handleEditContact"
@delete="handleDeleteContact"
/>
```
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | Array | `[]` | Array of contact objects |
| `loading` | Boolean | `false` | Shows loading skeleton |
| `skeletonRows` | Number | `5` | Number of skeleton rows to show |
### Events
| Event | Payload | Description |
|-------|---------|-------------|
| `@view` | `contactId` | Emitted when view button is clicked |
| `@edit` | `contactId` | Emitted when edit button is clicked |
| `@delete` | `contactId` | Emitted when delete button is clicked |
### Expected Data Structure
```javascript
const contacts = [
{
id: 1,
first_name: "John",
last_name: "Doe",
full_name: "John Doe", // Computed in backend
email: "john@example.com",
phone: "+33 1 23 45 67 89",
mobile: "+33 6 12 34 56 78",
position: "Sales Manager",
is_primary: true,
client: {
id: 5,
name: "Acme Corporation"
}
},
// ...
]
```
## Parent Component Example
```vue
<script setup>
import { ref } from 'vue';
import ContactTable from '@/components/molecules/Tables/ContactTable.vue';
import { contactApi } from '@/api/contacts';
const contacts = ref([]);
const loading = ref(false);
const loadContacts = async () => {
loading.value = true;
try {
const response = await contactApi.getAll();
contacts.value = response.data;
} catch (error) {
console.error('Error loading contacts:', error);
} finally {
loading.value = false;
}
};
const handleViewContact = (contactId) => {
router.push(`/contacts/${contactId}`);
};
const handleEditContact = (contactId) => {
router.push(`/contacts/${contactId}/edit`);
};
const handleDeleteContact = async (contactId) => {
if (confirm('Delete this contact?')) {
try {
await contactApi.delete(contactId);
await loadContacts(); // Reload list
} catch (error) {
console.error('Error deleting contact:', error);
}
}
};
onMounted(() => {
loadContacts();
});
</script>
<template>
<ContactTable
:data="contacts"
:loading="loading"
@view="handleViewContact"
@edit="handleEditContact"
@delete="handleDeleteContact"
/>
</template>
```
## Styling Features
- **Responsive design** - Adapts to mobile screens
- **Skeleton animations** - Shimmer and pulse effects
- **Icon badges** - For status indicators
- **Avatar images** - Random avatars for visual appeal
- **Consistent spacing** - Matches ClientTable design
## Dependencies
- `simple-datatables` - For table pagination and search
- `SoftButton` - Custom button component
- `SoftAvatar` - Avatar component
- Font Awesome icons
## File Modified
- `src/components/molecules/Tables/ContactTable.vue`
---
**Status**: ✅ Complete
**Date**: October 16, 2025

View File

@ -1,7 +1,7 @@
<template>
<contact-template>
<template #contact-new-action>
<add-button text="Ajouter" />
<add-button text="Ajouter" @click="goToCreateContact" />
</template>
<template #select-filter>
<filter-table />
@ -10,7 +10,7 @@
<table-action />
</template>
<template #contact-table>
<contact-table :contacts-data="contacts" />
<contact-table :data="contacts" />
</template>
</contact-template>
</template>
@ -21,6 +21,9 @@ import addButton from "@/components/molecules/new-button/addButton.vue";
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
import { defineProps } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
defineProps({
contacts: {
@ -28,4 +31,10 @@ defineProps({
default: [],
},
});
const goToCreateContact = () => {
router.push({
name: "Add Contact",
});
};
</script>

View File

@ -0,0 +1,45 @@
<template>
<new-contact-template>
<template #contact-form>
<contact-form
:loading="loading"
:success="success"
:validation-errors="validationErrors"
:search-results="searchResults"
@create-contact="$emit('create-contact', $event)"
@search-client="$emit('search-client', $event)"
/>
</template>
</new-contact-template>
</template>
<script setup>
import NewContactTemplate from "@/components/templates/CRM/contact/NewContactTemplate.vue";
import ContactForm from "@/components/molecules/form/ContactForm.vue";
import { defineProps, watch } from "vue";
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
success: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
searchResults: {
type: Array,
default: () => [],
},
});
watch(
() => props.searchResults,
(newResult, oldResult) => {
console.log(newResult);
}
);
</script>

View File

@ -1,38 +1,437 @@
<template>
<div class="table-responsive">
<table id="order-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Contact</th>
<th>Email</th>
<th>Phone</th>
<th>Date Creation</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<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">Loading...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Contact</th>
<th>Client</th>
<th>Email</th>
<th>Phone / Mobile</th>
<th>Position</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<td>
<div class="skeleton-text medium"></div>
</td>
<td>
<div class="skeleton-text long"></div>
</td>
<td>
<div class="skeleton-text medium"></div>
</td>
<td>
<div class="skeleton-text short"></div>
</td>
<td>
<div class="skeleton-icon"></div>
</td>
<td>
<div class="d-flex gap-2">
<div class="skeleton-icon small"></div>
<div class="skeleton-icon small"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="contact-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Contact</th>
<th>Client</th>
<th>Email</th>
<th>Phone / Mobile</th>
<th>Position</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="contact in data" :key="contact.id">
<!-- Contact Name Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
size="xs"
class="me-2"
alt="contact avatar"
circular
/>
<div>
<span class="text-sm">{{ contact.full_name }}</span>
</div>
</div>
</td>
<!-- Client Column -->
<td class="text-xs font-weight-bold">
<span v-if="contact.client">
{{ contact.client.name }}
</span>
<span v-else class="text-muted">-</span>
</td>
<!-- Email Column -->
<td class="text-xs">
<div class="text-secondary">
{{ contact.email || '-' }}
</div>
</td>
<!-- Phone / Mobile Column -->
<td class="text-xs">
<div>
<div v-if="contact.phone" class="mb-1">
<i class="fas fa-phone me-1"></i>{{ contact.phone }}
</div>
<div v-if="contact.mobile">
<i class="fas fa-mobile-alt me-1"></i>{{ contact.mobile }}
</div>
<span v-if="!contact.phone && !contact.mobile" class="text-muted">-</span>
</div>
</td>
<!-- Position Column -->
<td class="text-xs">
{{ contact.position || '-' }}
</td>
<!-- Status Column (Primary Contact Badge) -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
v-if="contact.is_primary"
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-star" aria-hidden="true"></i>
</soft-button>
<span v-if="contact.is_primary">Principal</span>
<span v-else class="text-muted">-</span>
</div>
</td>
<!-- Actions Column -->
<td>
<div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button
color="info"
variant="outline"
title="View Contact"
:data-contact-id="contact.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<!-- Edit Button -->
<soft-button
color="warning"
variant="outline"
title="Edit Contact"
:data-contact-id="contact.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-edit" aria-hidden="true"></i>
</soft-button>
<!-- Delete Button -->
<soft-button
color="danger"
variant="outline"
title="Delete Contact"
:data-contact-id="contact.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-address-book fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun contact trouvé</h5>
<p class="empty-text text-muted">
Aucun contact à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { onMounted } from "vue";
import { DataTable } from "simple-datatables";
import { defineProps } from "vue";
defineProps({
contacts: {
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps, defineEmits } from "vue";
const emit = defineEmits(["view", "edit", "delete"]);
// Sample avatar images
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
onMounted(() => {
const dataTableEl = document.getElementById("order-list");
// Methods
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("contact-list");
if (dataTableEl) {
new DataTable(dataTableEl, {
searchable: false,
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 10,
perPageSelect: [5, 10, 15, 20],
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const contactId = button.getAttribute("data-contact-id");
if (!contactId) return;
if (button.title === "Delete Contact" || button.querySelector(".fa-trash")) {
emit("delete", contactId);
} else if (button.title === "Edit Contact" || button.querySelector(".fa-edit")) {
emit("edit", contactId);
} else if (button.title === "View Contact" || button.querySelector(".fa-eye")) {
emit("view", contactId);
}
};
// Watch for data changes to reinitialize datatable
watch(
() => props.data,
() => {
if (!props.loading) {
// Small delay to ensure DOM is updated
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
const dataTableEl = document.getElementById("contact-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
// Initialize data
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-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-icon {
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-icon.small {
width: 24px;
height: 24px;
}
.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-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;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
</style>

View File

@ -0,0 +1,4 @@
<template></template>
<script setup>
import SoftInput from "@/components/SoftInput.vue";
</script>

View File

@ -0,0 +1,623 @@
<template>
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<h5 class="font-weight-bolder mb-0">Nouveau Contact</h5>
<p class="mb-0 text-sm">Informations du contact</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> Contact créé avec succès !</span
>
</div>
<!-- Message d'erreur général -->
<div
v-if="fieldErrors.general"
class="alert alert-danger alert-dismissible fade show mt-3"
role="alert"
>
<span class="alert-icon"><i class="ni ni-support-16"></i></span>
<span class="alert-text">
{{ fieldErrors.general }}
</span>
</div>
<div class="multisteps-form__content">
<!-- Recherche de client existant -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label"
>Client <span class="text-danger">*</span></label
>
<div class="search-container position-relative">
<soft-input
:value="searchQuery"
@input="handleSearchInput($event.target.value)"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.client_id }"
type="text"
placeholder="Tapez pour rechercher un client..."
icon="ni ni-zoom-split-in"
iconDir="left"
@focus="showDropdown = true"
@blur="onInputBlur"
/>
<div v-if="fieldErrors.client_id" class="invalid-feedback d-block">
{{ fieldErrors.client_id }}
</div>
<!-- Liste déroulante des résultats -->
<div
v-if="
showDropdown &&
props.searchResults &&
props.searchResults.length > 0
"
class="dropdown-results show position-absolute w-100"
>
<div class="list-group">
<button
v-for="client in props.searchResults"
:key="client.id"
type="button"
class="list-group-item list-group-item-action"
@mousedown="selectClient(client)"
>
<div
class="d-flex justify-content-between align-items-center"
>
<div>
<strong>{{ client.name }}</strong>
<div class="text-muted small" v-if="client.email">
{{ client.email }}
</div>
</div>
<i class="ni ni-check-bold text-success"></i>
</div>
</button>
</div>
</div>
<!-- Message aucun résultat -->
<div
v-if="
showDropdown &&
searchQuery &&
props.searchResults &&
props.searchResults.length === 0
"
class="dropdown-results show position-absolute w-100"
>
<div class="list-group">
<div class="list-group-item text-muted text-center">
<i class="ni ni-bulb-61 me-2"></i>
Aucun client trouvé pour "{{ searchQuery }}"
</div>
</div>
</div>
</div>
<small class="text-muted">
Sélectionnez un client existant pour associer le contact
</small>
</div>
</div>
<!-- Client sélectionné -->
<div
v-if="selectedClient"
class="selected-contact mt-3 p-3 bg-success bg-opacity-10 border-radius-lg"
>
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1 text-success">
<i class="ni ni-check-bold me-2"></i>
Client sélectionné
</h6>
<p class="mb-0">
<strong>{{ selectedClient.name }}</strong>
<span v-if="selectedClient.email">
{{ selectedClient.email }}</span
>
</p>
<small class="text-muted"> Le contact sera lié à ce client </small>
</div>
<button
type="button"
class="btn btn-outline-danger btn-sm"
@click="clearSelection"
>
<i class="ni ni-fat-remove"></i> Changer
</button>
</div>
</div>
<!-- Prénom et Nom -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Prénom</label>
<soft-input
:value="form.first_name"
@input="form.first_name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.first_name }"
type="text"
placeholder="ex. Jean"
maxlength="191"
/>
<div v-if="fieldErrors.first_name" class="invalid-feedback">
{{ fieldErrors.first_name }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Nom</label>
<soft-input
:value="form.last_name"
@input="form.last_name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.last_name }"
type="text"
placeholder="ex. Dupont"
maxlength="191"
/>
<div v-if="fieldErrors.last_name" class="invalid-feedback">
{{ fieldErrors.last_name }}
</div>
</div>
</div>
<!-- Email -->
<div class="row mt-3">
<div class="col-12">
<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. jean.dupont@entreprise.com"
maxlength="191"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
</div>
</div>
</div>
<!-- Téléphone et Mobile -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<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 class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Mobile</label>
<soft-input
:value="form.mobile"
@input="form.mobile = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.mobile }"
type="text"
placeholder="ex. +33 6 12 34 56 78"
maxlength="50"
/>
<div v-if="fieldErrors.mobile" class="invalid-feedback">
{{ fieldErrors.mobile }}
</div>
</div>
</div>
<!-- Poste/Rôle -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Rôle</label>
<soft-input
:value="form.role"
@input="form.role = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.role }"
type="text"
placeholder="ex. Directeur Commercial"
maxlength="191"
/>
<div v-if="fieldErrors.role" class="invalid-feedback">
{{ fieldErrors.role }}
</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 contact..."
maxlength="1000"
></textarea>
</div>
</div>
<!-- Validation message -->
<div
v-if="showValidationWarning"
class="alert alert-warning mt-3"
role="alert"
>
<i class="ni ni-notification-70 me-2"></i>
<strong>Attention :</strong> Au moins un champ (prénom, nom, email ou
téléphone) doit être renseigné.
</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 || !isFormValid"
@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 contact" }}
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, computed } 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,
},
searchResults: {
type: Array,
default: () => [],
},
});
// Emits
const emit = defineEmits(["createContact", "searchClient", "clientSelected"]);
// Reactive data
const errors = ref([]);
const fieldErrors = ref({});
const searchQuery = ref("");
const selectedClient = ref(null);
const showDropdown = ref(false);
const searchTimeout = ref(null);
const form = ref({
client_id: null,
first_name: "",
last_name: "",
email: "",
phone: "",
mobile: "",
role: "",
notes: "",
});
// Computed properties
const showValidationWarning = computed(() => {
return (
!form.value.first_name &&
!form.value.last_name &&
!form.value.email &&
!form.value.phone &&
!form.value.mobile
);
});
const isFormValid = computed(() => {
// Client est obligatoire
if (!form.value.client_id) return false;
// Au moins un champ de contact doit être rempli
if (showValidationWarning.value) return false;
return 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();
}
}
);
// Methods
const handleSearchInput = (value) => {
searchQuery.value = value;
// Clear previous timeout
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// If empty query, clear results and hide dropdown
if (!value.trim()) {
showDropdown.value = false;
return;
}
// Show dropdown
showDropdown.value = true;
// Set new timeout for debounce (300ms)
searchTimeout.value = setTimeout(() => {
// Emit search event to parent
emit("searchClient", value);
}, 300);
};
const selectClient = (client) => {
// Store the selected client
selectedClient.value = client;
form.value.client_id = client.id;
// Clear search and dropdown
searchQuery.value = "";
showDropdown.value = false;
// Clear client_id error if any
if (fieldErrors.value.client_id) {
delete fieldErrors.value.client_id;
}
// Emit client selection
emit("clientSelected", client.id);
};
const clearSelection = () => {
selectedClient.value = null;
form.value.client_id = null;
searchQuery.value = "";
showDropdown.value = false;
// Emit that client selection was cleared
emit("clientSelected", null);
};
const onInputBlur = () => {
// Petit délai pour permettre le clic sur les éléments de la liste
setTimeout(() => {
showDropdown.value = false;
}, 200);
};
const validateForm = () => {
const errors = {};
// Validation client_id
if (!form.value.client_id) {
errors.client_id = "Le client est obligatoire.";
}
// Validation des longueurs max
if (form.value.first_name && form.value.first_name.length > 191) {
errors.first_name = "Le prénom ne peut pas dépasser 191 caractères.";
}
if (form.value.last_name && form.value.last_name.length > 191) {
errors.last_name = "Le nom ne peut pas dépasser 191 caractères.";
}
if (form.value.email && form.value.email.length > 191) {
errors.email = "L'adresse email ne peut pas dépasser 191 caractères.";
}
if (form.value.phone && form.value.phone.length > 50) {
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères.";
}
if (form.value.mobile && form.value.mobile.length > 50) {
errors.mobile = "Le mobile ne peut pas dépasser 50 caractères.";
}
if (form.value.role && form.value.role.length > 191) {
errors.role = "Le rôle ne peut pas dépasser 191 caractères.";
}
// Validation email format
if (form.value.email && !isValidEmail(form.value.email)) {
errors.email = "L'adresse email doit être valide.";
}
// Validation règle générale (au moins un champ rempli)
if (
!form.value.first_name &&
!form.value.last_name &&
!form.value.email &&
!form.value.phone &&
!form.value.mobile
) {
errors.general =
"Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
}
return errors;
};
const isValidEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Validate form locally first
const localErrors = validateForm();
if (Object.keys(localErrors).length > 0) {
fieldErrors.value = localErrors;
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;
}
}
console.log("Form data being emitted:", cleanedForm);
// Emit the cleaned form data to parent
emit("createContact", cleanedForm);
};
const resetForm = () => {
form.value = {
client_id: null,
first_name: "",
last_name: "",
email: "",
phone: "",
mobile: "",
role: "",
notes: "",
};
selectedClient.value = null;
searchQuery.value = "";
showDropdown.value = false;
clearErrors();
};
const clearErrors = () => {
errors.value = [];
fieldErrors.value = {};
};
</script>
<style scoped>
.search-container {
z-index: 1000;
}
.dropdown-results {
top: 100%;
left: 0;
z-index: 1050;
max-height: 300px;
overflow-y: auto;
background: white;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
margin-top: 2px;
}
.list-group-item {
border: none;
border-bottom: 1px solid #f8f9fa;
padding: 0.75rem 1rem;
cursor: pointer;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
.list-group-item:last-child {
border-bottom: none;
}
.selected-contact {
border: 1px solid #2dce89;
}
.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;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<div class="container-fluid">
<div class="py-4 container fluid">
<div class="mt-3 row">
<div class="col-12 col-md-6 col-xl-4 mt-md-0">
<slot name="contact-information" />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,14 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<!--form panels-->
<div class="row">
<div class="col-12 col-lg-8 m-auto">
<slot name="contact-form" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -390,6 +390,11 @@ const routes = [
name: "Client details",
component: () => import("@/views/pages/CRM/ClientDetails.vue"),
},
{
path: "/crm/new-contact",
name: "Add Contact",
component: () => import("@/views/pages/CRM/AddContact.vue"),
},
];
const router = createRouter({

View File

@ -168,20 +168,23 @@ export const ClientService = {
async searchClients(
query: string,
params?: {
page?: number;
per_page?: number;
// Remove pagination parameters since we're not using pagination
exact_match?: boolean;
}
): Promise<ClientListResponse> {
const response = await request<ClientListResponse>({
url: "/api/clients",
): Promise<Client[]> {
const response = await request<{
data: Client[];
count: number;
message: string;
}>({
url: "/api/clients/searchBy",
method: "get",
params: {
search: query,
...params,
name: query, // Changed from 'search' to 'name' to match your controller
exact_match: params?.exact_match || false,
},
});
return response;
return response.data; // Return just the data array
},
/**

View File

@ -15,6 +15,7 @@ export const useClientStore = defineStore("client", () => {
const currentClient = ref<Client | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const searchResults = ref<Client[]>([]);
// Pagination state
const pagination = ref({
@ -61,6 +62,10 @@ export const useClientStore = defineStore("client", () => {
currentClient.value = client;
};
const setSearchClient = (searchClient: Client[]) => {
searchResults.value = searchClient;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
@ -213,29 +218,20 @@ export const useClientStore = defineStore("client", () => {
/**
* Search clients
*/
const searchClients = async (
query: string,
params?: {
page?: number;
per_page?: number;
}
) => {
const searchClients = async (query: string, exactMatch: boolean = false) => {
setLoading(true);
setError(null);
error.value = null;
try {
const response = await ClientService.searchClients(query, params);
setClients(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to search clients";
setError(errorMessage);
const results = await ClientService.searchClients(query, {
exact_match: exactMatch,
});
setSearchClient(results);
return results;
} catch (err) {
error.value = "Erreur lors de la recherche des clients";
console.error("Error searching clients:", err);
setSearchClient([]);
throw err;
} finally {
setLoading(false);

View File

@ -0,0 +1,26 @@
<template>
<add-contact-presentation
:search-results="resultSeach"
@create-contact="handleCreateContact"
@search-client="handleSearchClient"
/>
</template>
<script setup>
import AddContactPresentation from "@/components/Organism/CRM/contact/AddContactPresentation.vue";
import useContactStore from "@/stores/contactStore";
import { useClientStore } from "@/stores/clientStore";
import { ref } from "vue";
const contactStore = useContactStore();
const clientStore = useClientStore();
const resultSeach = ref([]);
const handleCreateContact = async (contactData) => {
await contactStore.createContact(contactData);
};
const handleSearchClient = async (searchInput) => {
resultSeach.value = await clientStore.searchClients(searchInput);
};
</script>

View File

@ -1,5 +1,5 @@
<template>
<contact-presentation />
<contact-presentation :contacts="contactStore.contacts" />
</template>
<script setup>
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";