contact CRUD
This commit is contained in:
parent
c5a4fcc546
commit
98420a29b5
@ -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.
|
* Update the specified client.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\StoreContactRequest;
|
use App\Http\Requests\StoreContactRequest;
|
||||||
use App\Http\Requests\UpdateContactRequest;
|
use App\Http\Requests\UpdateContactRequest;
|
||||||
use App\Http\Resources\Contact\ContactResource;
|
use App\Http\Resources\Contact\ContactResource;
|
||||||
|
use App\Http\Resources\Contact\ContactCollection;
|
||||||
use App\Repositories\ContactRepositoryInterface;
|
use App\Repositories\ContactRepositoryInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@ -23,11 +24,21 @@ class ContactController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display a listing of contacts.
|
* Display a listing of contacts.
|
||||||
*/
|
*/
|
||||||
public function index(): AnonymousResourceCollection|JsonResponse
|
public function index(): ContactCollection
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$contacts = $this->contactRepository->all();
|
$perPage = request('per_page', 15);
|
||||||
return ContactResource::collection($contacts);
|
$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) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error fetching contacts: ' . $e->getMessage(), [
|
Log::error('Error fetching contacts: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ class ContactCollection extends ResourceCollection
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'data' => $this->collection,
|
'data' => ContactResource::collection($this->collection),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'total' => $this->total(),
|
'total' => $this->total(),
|
||||||
'per_page' => $this->perPage(),
|
'per_page' => $this->perPage(),
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources\Contact;
|
namespace App\Http\Resources\Contact;
|
||||||
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@ -27,7 +28,10 @@ class ContactResource extends JsonResource
|
|||||||
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
'client' => new ClientResource($this->whenLoaded('client')),
|
'client' => $this->whenLoaded('client', [
|
||||||
|
'id' => $this->client->id,
|
||||||
|
'name' => $this->client->name,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class Contact extends Model
|
class Contact extends Model
|
||||||
{
|
{
|
||||||
@ -15,9 +16,23 @@ class Contact extends Model
|
|||||||
'position',
|
'position',
|
||||||
'notes',
|
'notes',
|
||||||
'is_primary',
|
'is_primary',
|
||||||
|
'client_id'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_primary' => 'boolean',
|
'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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,4 +52,18 @@ class ClientRepository extends BaseRepository implements ClientRepositoryInterfa
|
|||||||
|
|
||||||
return $query->paginate($perPage);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class ContactRepository extends BaseRepository implements ContactRepositoryInterface
|
class ContactRepository extends BaseRepository implements ContactRepositoryInterface
|
||||||
{
|
{
|
||||||
@ -12,4 +13,46 @@ class ContactRepository extends BaseRepository implements ContactRepositoryInter
|
|||||||
{
|
{
|
||||||
parent::__construct($model);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,5 +6,5 @@ namespace App\Repositories;
|
|||||||
|
|
||||||
interface ContactRepositoryInterface extends BaseRepositoryInterface
|
interface ContactRepositoryInterface extends BaseRepositoryInterface
|
||||||
{
|
{
|
||||||
// Add Contact-specific methods here later if needed
|
function paginate(int $perPage = 15, array $filters = []);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,9 @@ Route::prefix('auth')->group(function () {
|
|||||||
// Protected API routes
|
// Protected API routes
|
||||||
Route::middleware('auth:sanctum')->group(function () {
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
// Client management
|
// Client management
|
||||||
|
// IMPORTANT: Specific routes must come before apiResource
|
||||||
|
Route::get('/clients/searchBy', [ClientController::class, 'searchBy']);
|
||||||
|
|
||||||
Route::apiResource('clients', ClientController::class);
|
Route::apiResource('clients', ClientController::class);
|
||||||
Route::apiResource('client-groups', ClientGroupController::class);
|
Route::apiResource('client-groups', ClientGroupController::class);
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
|
|||||||
120
thanasoft-front/CONTACTFORM_CLIENT_SELECTION.md
Normal file
120
thanasoft-front/CONTACTFORM_CLIENT_SELECTION.md
Normal 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`
|
||||||
95
thanasoft-front/CONTACTFORM_FIX.md
Normal file
95
thanasoft-front/CONTACTFORM_FIX.md
Normal 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`
|
||||||
181
thanasoft-front/CONTACT_TABLE_UPDATE.md
Normal file
181
thanasoft-front/CONTACT_TABLE_UPDATE.md
Normal 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
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<contact-template>
|
<contact-template>
|
||||||
<template #contact-new-action>
|
<template #contact-new-action>
|
||||||
<add-button text="Ajouter" />
|
<add-button text="Ajouter" @click="goToCreateContact" />
|
||||||
</template>
|
</template>
|
||||||
<template #select-filter>
|
<template #select-filter>
|
||||||
<filter-table />
|
<filter-table />
|
||||||
@ -10,7 +10,7 @@
|
|||||||
<table-action />
|
<table-action />
|
||||||
</template>
|
</template>
|
||||||
<template #contact-table>
|
<template #contact-table>
|
||||||
<contact-table :contacts-data="contacts" />
|
<contact-table :data="contacts" />
|
||||||
</template>
|
</template>
|
||||||
</contact-template>
|
</contact-template>
|
||||||
</template>
|
</template>
|
||||||
@ -21,6 +21,9 @@ import addButton from "@/components/molecules/new-button/addButton.vue";
|
|||||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||||
import { defineProps } from "vue";
|
import { defineProps } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
@ -28,4 +31,10 @@ defineProps({
|
|||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const goToCreateContact = () => {
|
||||||
|
router.push({
|
||||||
|
name: "Add Contact",
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -1,38 +1,437 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="table-container">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="loading-container">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-content">
|
||||||
|
<!-- Skeleton Rows -->
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table id="order-list" class="table table-flush">
|
<table class="table table-flush">
|
||||||
<thead class="thead-light">
|
<thead class="thead-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Contact</th>
|
<th>Contact</th>
|
||||||
|
<th>Client</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone / Mobile</th>
|
||||||
<th>Date Creation</th>
|
<th>Position</th>
|
||||||
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<script setup>
|
</div>
|
||||||
import { onMounted } from "vue";
|
|
||||||
import { DataTable } from "simple-datatables";
|
|
||||||
import { defineProps } from "vue";
|
|
||||||
|
|
||||||
defineProps({
|
<!-- Data State -->
|
||||||
contacts: {
|
<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 { 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,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
skeletonRows: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
// Methods
|
||||||
const dataTableEl = document.getElementById("order-list");
|
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) {
|
if (dataTableEl) {
|
||||||
new DataTable(dataTableEl, {
|
dataTableInstance.value = new DataTable(dataTableEl, {
|
||||||
searchable: false,
|
searchable: true,
|
||||||
fixedHeight: 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>
|
</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>
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
<template></template>
|
||||||
|
<script setup>
|
||||||
|
import SoftInput from "@/components/SoftInput.vue";
|
||||||
|
</script>
|
||||||
623
thanasoft-front/src/components/molecules/form/ContactForm.vue
Normal file
623
thanasoft-front/src/components/molecules/form/ContactForm.vue
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -390,6 +390,11 @@ const routes = [
|
|||||||
name: "Client details",
|
name: "Client details",
|
||||||
component: () => import("@/views/pages/CRM/ClientDetails.vue"),
|
component: () => import("@/views/pages/CRM/ClientDetails.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/crm/new-contact",
|
||||||
|
name: "Add Contact",
|
||||||
|
component: () => import("@/views/pages/CRM/AddContact.vue"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@ -168,20 +168,23 @@ export const ClientService = {
|
|||||||
async searchClients(
|
async searchClients(
|
||||||
query: string,
|
query: string,
|
||||||
params?: {
|
params?: {
|
||||||
page?: number;
|
// Remove pagination parameters since we're not using pagination
|
||||||
per_page?: number;
|
exact_match?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<ClientListResponse> {
|
): Promise<Client[]> {
|
||||||
const response = await request<ClientListResponse>({
|
const response = await request<{
|
||||||
url: "/api/clients",
|
data: Client[];
|
||||||
|
count: number;
|
||||||
|
message: string;
|
||||||
|
}>({
|
||||||
|
url: "/api/clients/searchBy",
|
||||||
method: "get",
|
method: "get",
|
||||||
params: {
|
params: {
|
||||||
search: query,
|
name: query, // Changed from 'search' to 'name' to match your controller
|
||||||
...params,
|
exact_match: params?.exact_match || false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return response.data; // Return just the data array
|
||||||
return response;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
const currentClient = ref<Client | null>(null);
|
const currentClient = ref<Client | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const searchResults = ref<Client[]>([]);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@ -61,6 +62,10 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
currentClient.value = client;
|
currentClient.value = client;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSearchClient = (searchClient: Client[]) => {
|
||||||
|
searchResults.value = searchClient;
|
||||||
|
};
|
||||||
|
|
||||||
const setPagination = (meta: any) => {
|
const setPagination = (meta: any) => {
|
||||||
if (meta) {
|
if (meta) {
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
@ -213,29 +218,20 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
/**
|
/**
|
||||||
* Search clients
|
* Search clients
|
||||||
*/
|
*/
|
||||||
const searchClients = async (
|
const searchClients = async (query: string, exactMatch: boolean = false) => {
|
||||||
query: string,
|
|
||||||
params?: {
|
|
||||||
page?: number;
|
|
||||||
per_page?: number;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ClientService.searchClients(query, params);
|
const results = await ClientService.searchClients(query, {
|
||||||
setClients(response.data);
|
exact_match: exactMatch,
|
||||||
if (response.meta) {
|
});
|
||||||
setPagination(response.meta);
|
setSearchClient(results);
|
||||||
}
|
return results;
|
||||||
return response;
|
} catch (err) {
|
||||||
} catch (err: any) {
|
error.value = "Erreur lors de la recherche des clients";
|
||||||
const errorMessage =
|
console.error("Error searching clients:", err);
|
||||||
err.response?.data?.message ||
|
setSearchClient([]);
|
||||||
err.message ||
|
|
||||||
"Failed to search clients";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
26
thanasoft-front/src/views/pages/CRM/AddContact.vue
Normal file
26
thanasoft-front/src/views/pages/CRM/AddContact.vue
Normal 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>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<contact-presentation />
|
<contact-presentation :contacts="contactStore.contacts" />
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";
|
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user