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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Resources\Contact;
|
||||
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
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'),
|
||||
|
||||
// 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;
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = []);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
|
||||
@ -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",
|
||||
component: () => import("@/views/pages/CRM/ClientDetails.vue"),
|
||||
},
|
||||
{
|
||||
path: "/crm/new-contact",
|
||||
name: "Add Contact",
|
||||
component: () => import("@/views/pages/CRM/AddContact.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
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>
|
||||
<contact-presentation />
|
||||
<contact-presentation :contacts="contactStore.contacts" />
|
||||
</template>
|
||||
<script setup>
|
||||
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user