detail client
This commit is contained in:
parent
98420a29b5
commit
e2cb4499bb
@ -164,4 +164,27 @@ class ContactController extends Controller
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function getContactsByClient(string $clientId): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$intId = (int) $clientId;
|
||||||
|
$contacts = $this->contactRepository->getByClientId($intId);
|
||||||
|
return response()->json([
|
||||||
|
'data' => ContactResource::collection($contacts),
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching contacts by client: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'client_id' => $clientId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération des contacts du client.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,4 +55,11 @@ class ContactRepository extends BaseRepository implements ContactRepositoryInter
|
|||||||
|
|
||||||
return $query->paginate($perPage);
|
return $query->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getByClientId(int $clientId)
|
||||||
|
{
|
||||||
|
return $this->model->newQuery()
|
||||||
|
->where('client_id', $clientId)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,4 +7,6 @@ namespace App\Repositories;
|
|||||||
interface ContactRepositoryInterface extends BaseRepositoryInterface
|
interface ContactRepositoryInterface extends BaseRepositoryInterface
|
||||||
{
|
{
|
||||||
function paginate(int $perPage = 15, array $filters = []);
|
function paginate(int $perPage = 15, array $filters = []);
|
||||||
|
|
||||||
|
function getByClientId(int $clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,8 +41,10 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::apiResource('client-groups', ClientGroupController::class);
|
Route::apiResource('client-groups', ClientGroupController::class);
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
|
|
||||||
|
|
||||||
// Contact management
|
// Contact management
|
||||||
Route::apiResource('contacts', ContactController::class);
|
Route::apiResource('contacts', ContactController::class);
|
||||||
|
Route::get('clients/{clientId}/contacts', [ContactController::class, 'getContactsByClient']);
|
||||||
|
|
||||||
Route::apiResource('client-categories', ClientCategoryController::class);
|
Route::apiResource('client-categories', ClientCategoryController::class);
|
||||||
|
|
||||||
|
|||||||
259
thanasoft-front/CLIENT_DETAIL_NEW.md
Normal file
259
thanasoft-front/CLIENT_DETAIL_NEW.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# New Client Detail Page - Modern Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A completely redesigned client detail page with modern UI/UX, inspired by the Settings page structure with tabs, client avatar/logo, and better ergonomics.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎨 **Modern Layout**
|
||||||
|
- **Left Sidebar**: Sticky client card with navigation
|
||||||
|
- **Right Content Area**: Tabbed content with clean organization
|
||||||
|
|
||||||
|
### 👤 **Client Profile Card**
|
||||||
|
- **Avatar/Logo**:
|
||||||
|
- Shows client initials if no image
|
||||||
|
- Click edit button to upload logo
|
||||||
|
- Large, prominent display
|
||||||
|
- **Quick Stats**:
|
||||||
|
- Number of contacts
|
||||||
|
- Active/Inactive status
|
||||||
|
- **Client Info**: Name and type
|
||||||
|
|
||||||
|
### 📑 **5 Tab Sections**
|
||||||
|
|
||||||
|
#### 1. **Aperçu (Overview)**
|
||||||
|
Default view with key information:
|
||||||
|
- Contact info card (email, phone)
|
||||||
|
- Business info card (SIRET, VAT)
|
||||||
|
- Address card
|
||||||
|
- Recent contacts (first 3)
|
||||||
|
- Edit button to modify client
|
||||||
|
|
||||||
|
#### 2. **Informations (Information)**
|
||||||
|
Complete client details:
|
||||||
|
- Name, type
|
||||||
|
- SIRET, VAT number
|
||||||
|
- Email, phone
|
||||||
|
- Commercial
|
||||||
|
|
||||||
|
#### 3. **Contacts**
|
||||||
|
Full contact list with:
|
||||||
|
- Contact table with avatars
|
||||||
|
- Email, phone, position
|
||||||
|
- Primary contact badge
|
||||||
|
- "Add Contact" button
|
||||||
|
- Empty state if no contacts
|
||||||
|
|
||||||
|
#### 4. **Adresse (Address)**
|
||||||
|
Billing address details:
|
||||||
|
- Address line 1 & 2
|
||||||
|
- Postal code, city, country
|
||||||
|
|
||||||
|
#### 5. **Notes**
|
||||||
|
Client notes section
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
```
|
||||||
|
src/views/pages/CRM/ClientDetailNew.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Update Router
|
||||||
|
Add the new route in your router configuration:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// router/index.js
|
||||||
|
{
|
||||||
|
path: '/clients/:id/detail',
|
||||||
|
name: 'ClientDetailNew',
|
||||||
|
component: () => import('@/views/pages/CRM/ClientDetailNew.vue')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replace Old ClientDetail
|
||||||
|
To use this as the main client detail page:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Change existing route
|
||||||
|
{
|
||||||
|
path: '/clients/:id',
|
||||||
|
name: 'ClientDetail',
|
||||||
|
component: () => import('@/views/pages/CRM/ClientDetailNew.vue') // Changed from ClientDetails.vue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
### Template Sections
|
||||||
|
1. **Header** - Back button to clients list
|
||||||
|
2. **Loading State** - Spinner while fetching data
|
||||||
|
3. **Error State** - Alert for errors
|
||||||
|
4. **Main Content**
|
||||||
|
- Left: Client card + navigation
|
||||||
|
- Right: Tab content
|
||||||
|
|
||||||
|
### Script Features
|
||||||
|
```javascript
|
||||||
|
// Reactive data
|
||||||
|
const activeTab = ref('overview') // Current tab
|
||||||
|
const clientAvatar = ref(null) // Client logo/avatar
|
||||||
|
const contacts_client = ref([]) // Client contacts
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
getInitials(name) // Generate initials from name
|
||||||
|
formatAddress(client) // Format full address string
|
||||||
|
triggerFileInput() // Open file selector
|
||||||
|
handleAvatarUpload() // Handle logo upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling Features
|
||||||
|
- **Sticky sidebar** - Stays visible when scrolling
|
||||||
|
- **Active tab highlighting** - Gradient background
|
||||||
|
- **Smooth transitions** - Hover effects
|
||||||
|
- **Responsive design** - Works on mobile
|
||||||
|
- **Clean cards** - Soft shadows and borders
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
- **Primary Actions**: Gradient purple-pink (`#7928ca` to `#ff0080`)
|
||||||
|
- **Success**: Green for active status and badges
|
||||||
|
- **Info**: Blue for contact info
|
||||||
|
- **Warning**: Yellow for business info
|
||||||
|
- **Danger**: Red for inactive status
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- **Headers**: Bold, clear hierarchy
|
||||||
|
- **Body**: `text-sm` for readability
|
||||||
|
- **Labels**: Uppercase with spacing
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
Using Font Awesome:
|
||||||
|
- 📊 `fa-eye` - Overview
|
||||||
|
- ℹ️ `fa-info-circle` - Information
|
||||||
|
- 👥 `fa-users` - Contacts
|
||||||
|
- 📍 `fa-map-marker-alt` - Address
|
||||||
|
- 📝 `fa-sticky-note` - Notes
|
||||||
|
|
||||||
|
## Avatar/Logo Upload
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
```javascript
|
||||||
|
handleAvatarUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
clientAvatar.value = e.target.result;
|
||||||
|
// TODO: Upload to server
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TODO: Connect to Backend
|
||||||
|
To implement server upload:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const handleAvatarUpload = async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
formData.append('client_id', clientStore.currentClient.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/clients/upload-avatar', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
clientAvatar.value = response.data.avatar_url;
|
||||||
|
// Update store
|
||||||
|
await clientStore.fetchClient(client_id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison: Old vs New
|
||||||
|
|
||||||
|
### Old Design
|
||||||
|
- Single page layout
|
||||||
|
- All info visible at once
|
||||||
|
- No image/avatar support
|
||||||
|
- Basic styling
|
||||||
|
- No tab organization
|
||||||
|
|
||||||
|
### New Design ✨
|
||||||
|
- Modern tabbed interface
|
||||||
|
- Client avatar/logo with upload
|
||||||
|
- Sticky sidebar navigation
|
||||||
|
- Card-based organization
|
||||||
|
- Better mobile responsive
|
||||||
|
- Visual hierarchy
|
||||||
|
- Quick stats
|
||||||
|
- Empty states
|
||||||
|
- Badge indicators
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Change Tab Order
|
||||||
|
```vue
|
||||||
|
<!-- Reorder nav items in the sidebar -->
|
||||||
|
<ul class="nav nav-pills flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a @click="activeTab = 'your-tab'">...</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add New Tab
|
||||||
|
1. Add nav item in sidebar
|
||||||
|
2. Add content section with `v-show`
|
||||||
|
3. Update `activeTab` ref
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Nav item -->
|
||||||
|
<li class="nav-item pt-2">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'documents' }"
|
||||||
|
@click="activeTab = 'documents'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-file me-2"></i>
|
||||||
|
<span class="text-sm">Documents</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-show="activeTab === 'documents'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Documents</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Your content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Vue 3 Composition API
|
||||||
|
- Vue Router
|
||||||
|
- Pinia stores (clientStore, contactStore)
|
||||||
|
- Font Awesome icons
|
||||||
|
- Bootstrap 5 classes
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
- Chrome, Firefox, Safari (latest)
|
||||||
|
- Edge (latest)
|
||||||
|
- Mobile browsers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: ✅ Ready to use
|
||||||
|
**Created**: October 20, 2025
|
||||||
|
**File**: `src/views/pages/CRM/ClientDetailNew.vue`
|
||||||
@ -1,48 +1,151 @@
|
|||||||
<template>
|
<template>
|
||||||
<client-detail-template>
|
<client-detail-template>
|
||||||
<template #client-information>
|
<template #button-return>
|
||||||
<client-information
|
<div class="col-12">
|
||||||
:client_id="client.id"
|
<router-link
|
||||||
:name="client.name"
|
to="/crm/clients"
|
||||||
:categorie="client.categorie"
|
class="btn btn-outline-secondary btn-sm mb-3"
|
||||||
:siret="client.siret"
|
>
|
||||||
:vat_number="client.vat_number"
|
<i class="fas fa-arrow-left me-2"></i>Retour aux clients
|
||||||
:phone="client.phone"
|
</router-link>
|
||||||
:email="client.email"
|
</div>
|
||||||
:billing_address_line1="client.billing_address_line1"
|
</template>
|
||||||
:billing_postal_code="client.billing_postal_code"
|
<template #loading-state>
|
||||||
:billing_city="client.billing_city"
|
<div v-if="isLoading" class="text-center p-5">
|
||||||
:billing_country_code="client.billing_country_code"
|
<div class="spinner-border text-primary" role="status">
|
||||||
:notes="client.notes"
|
<span class="visually-hidden">Chargement...</span>
|
||||||
:is_active="client.is_active"
|
</div>
|
||||||
:action="{
|
</div>
|
||||||
route: `/clients/${client.id}/edit`,
|
</template>
|
||||||
tooltip: 'Modifier les informations'
|
<template #client-detail-sidebar>
|
||||||
}"
|
<ClientDetailSidebar
|
||||||
@update:client="handleUpdateClient"
|
:avatar-url="clientAvatar"
|
||||||
|
:initials="getInitials(client.name)"
|
||||||
|
:client-name="client.name"
|
||||||
|
:client-type="client.type_label || 'Client'"
|
||||||
|
:contacts-count="client.length"
|
||||||
|
:is-active="client.is_active"
|
||||||
|
:active-tab="activeTab"
|
||||||
|
@edit-avatar="triggerFileInput"
|
||||||
|
@change-tab="activeTab = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #contact-list>
|
<template #file-input>
|
||||||
<list-contact />
|
<input
|
||||||
|
:ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="d-none"
|
||||||
|
accept="image/*"
|
||||||
|
@change="handleAvatarUpload"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #client-detail-content>
|
||||||
|
<ClientDetailContent
|
||||||
|
:active-tab="activeTab"
|
||||||
|
:client="client"
|
||||||
|
:contacts="contacts"
|
||||||
|
:formatted-address="formatAddress(client)"
|
||||||
|
:client-id="client.id"
|
||||||
|
:contact-is-loading="contactLoading"
|
||||||
|
@change-tab="activeTab = $event"
|
||||||
|
@updating-client="handleUpdateClient"
|
||||||
|
@create-contact="handleAddContact"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</client-detail-template>
|
</client-detail-template>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits, ref } from "vue";
|
||||||
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
|
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
|
||||||
import ClientInformation from "@/components/molecules/client/ClientInformation.vue";
|
import ClientDetailSidebar from "./client/ClientDetailSidebar.vue";
|
||||||
import ListContact from "@/components/molecules/client/ListContact.vue";
|
import ClientDetailContent from "./client/ClientDetailContent.vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
client: {
|
client: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
clientAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
activeTab: {
|
||||||
|
type: String,
|
||||||
|
default: "overview",
|
||||||
|
},
|
||||||
|
fileInput: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contactLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:client"]);
|
const localAvatar = ref(props.clientAvatar);
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"updateTheClient",
|
||||||
|
"handleFileInput",
|
||||||
|
"add-new-contact",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
localAvatar.value = e.target.result;
|
||||||
|
// TODO: Upload to server
|
||||||
|
console.log("Upload avatar to server");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateClient = (updateData) => {
|
const handleUpdateClient = (updateData) => {
|
||||||
emit("update:client", updateData);
|
emit("updateTheClient", updateData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputFile = () => {
|
||||||
|
emit("handleFileInput");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddContact = (data) => {
|
||||||
|
// TODO: Implement add contact functionality
|
||||||
|
emit("add-new-contact", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return "?";
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAddress = (client) => {
|
||||||
|
const parts = [
|
||||||
|
client.billing_address_line1,
|
||||||
|
client.billing_address_line2,
|
||||||
|
client.billing_postal_code,
|
||||||
|
client.billing_city,
|
||||||
|
client.billing_country_code,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(", ") : "Aucune adresse renseignée";
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
<template #contact-table>
|
<template #contact-table>
|
||||||
<contact-table :data="contacts" />
|
<contact-table :data="contacts" />
|
||||||
</template>
|
</template>
|
||||||
</contact-template>
|
</contact-template>s
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ContactTemplate from "@/components/templates/CRM/ContactTemplate.vue";
|
import ContactTemplate from "@/components/templates/CRM/ContactTemplate.vue";
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div v-show="activeTab === 'overview'">
|
||||||
|
<ClientOverview
|
||||||
|
:client="client"
|
||||||
|
:contacts="contacts"
|
||||||
|
:formatted-address="formattedAddress"
|
||||||
|
:client-id="clientId"
|
||||||
|
@view-all-contacts="$emit('change-tab', 'contacts')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information Tab -->
|
||||||
|
<div v-show="activeTab === 'info'">
|
||||||
|
<ClientInfoTab :client="client" @client-updated="updateClient" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts Tab -->
|
||||||
|
<div v-show="activeTab === 'contacts'">
|
||||||
|
<ClientContactsTab
|
||||||
|
:contacts="contacts"
|
||||||
|
:client-id="client.id"
|
||||||
|
:is-loading="contactIsLoading"
|
||||||
|
@contact-created="handleCreateContact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Tab -->
|
||||||
|
<div v-show="activeTab === 'address'">
|
||||||
|
<ClientAddressTab :client="client" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes Tab -->
|
||||||
|
<div v-show="activeTab === 'notes'">
|
||||||
|
<ClientNotesTab :notes="client.notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ClientOverview from "@/components/molecules/client/ClientOverview.vue";
|
||||||
|
import ClientInfoTab from "@/components/molecules/client/ClientInfoTab.vue";
|
||||||
|
import ClientContactsTab from "@/components/molecules/client/ClientContactsTab.vue";
|
||||||
|
import ClientAddressTab from "@/components/molecules/client/ClientAddressTab.vue";
|
||||||
|
import ClientNotesTab from "@/components/molecules/client/ClientNotesTab.vue";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
activeTab: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
formattedAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "Aucune adresse renseignée",
|
||||||
|
},
|
||||||
|
clientId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contactIsLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["change-tab", "create-contact", "updatingClient"]);
|
||||||
|
const updateClient = (updatedClient) => {
|
||||||
|
emit("updatingClient", updatedClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateContact = (newContact) => {
|
||||||
|
emit("create-contact", newContact);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card position-sticky top-1">
|
||||||
|
<!-- Client Profile Card -->
|
||||||
|
<ClientProfileCard
|
||||||
|
:avatar-url="avatarUrl"
|
||||||
|
:initials="initials"
|
||||||
|
:client-name="clientName"
|
||||||
|
:client-type="clientType"
|
||||||
|
:contacts-count="contactsCount"
|
||||||
|
:is-active="isActive"
|
||||||
|
@edit-avatar="$emit('edit-avatar')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="horizontal dark my-3 mx-3" />
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="card-body pt-0">
|
||||||
|
<ClientTabNavigation
|
||||||
|
:active-tab="activeTab"
|
||||||
|
:contacts-count="contactsCount"
|
||||||
|
@change-tab="$emit('change-tab', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ClientProfileCard from "@/components/molecules/client/ClientProfileCard.vue";
|
||||||
|
import ClientTabNavigation from "@/components/molecules/client/ClientTabNavigation.vue";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
avatarUrl: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initials: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
clientType: {
|
||||||
|
type: String,
|
||||||
|
default: "Client",
|
||||||
|
},
|
||||||
|
contactsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
activeTab: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["edit-avatar", "change-tab"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.position-sticky {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
thanasoft-front/src/components/atoms/client/ClientAvatar.vue
Normal file
64
thanasoft-front/src/components/atoms/client/ClientAvatar.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="avatar avatar-xxl position-relative mx-auto mb-3">
|
||||||
|
<img
|
||||||
|
v-if="avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
:alt="alt"
|
||||||
|
class="w-100 border-radius-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="avatar-placeholder w-100 border-radius-lg shadow-sm d-flex align-items-center justify-content-center bg-gradient-primary"
|
||||||
|
>
|
||||||
|
<span class="text-white text-lg font-weight-bold">
|
||||||
|
{{ initials }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Edit Avatar Button -->
|
||||||
|
<a
|
||||||
|
v-if="editable"
|
||||||
|
href="javascript:;"
|
||||||
|
class="btn btn-sm btn-icon-only bg-gradient-light position-absolute bottom-0 end-0 mb-n2 me-n2"
|
||||||
|
@click="$emit('edit')"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="fa fa-pen top-0"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
title="Modifier l'image"
|
||||||
|
></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
avatarUrl: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initials: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: "Avatar",
|
||||||
|
},
|
||||||
|
editable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["edit"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
thanasoft-front/src/components/atoms/client/InfoCard.vue
Normal file
24
thanasoft-front/src/components/atoms/client/InfoCard.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card shadow-none border">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i :class="icon" class="me-2"></i>{{ title }}
|
||||||
|
</h6>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: "fas fa-info-circle text-primary",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
27
thanasoft-front/src/components/atoms/client/StatusBadge.vue
Normal file
27
thanasoft-front/src/components/atoms/client/StatusBadge.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<span class="badge badge-sm" :class="badgeClass">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: "primary",
|
||||||
|
validator: (value) =>
|
||||||
|
["primary", "success", "warning", "danger", "info", "secondary"].includes(
|
||||||
|
value
|
||||||
|
),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeClass = computed(() => `bg-gradient-${props.status}`);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<li class="nav-item" :class="spacing">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: isActive }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<i :class="icon" class="me-2"></i>
|
||||||
|
<span class="text-sm">{{ label }}</span>
|
||||||
|
<span v-if="badge" class="badge badge-sm bg-gradient-success ms-auto">
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
type: String,
|
||||||
|
default: "pt-2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["click"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-pills .nav-link {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: #67748e;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link.active {
|
||||||
|
background: linear-gradient(310deg, #7928ca, #ff0080);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.09),
|
||||||
|
0 2px 3px -1px rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link i {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Adresse de facturation</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.billing_address_line1 || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-3">
|
||||||
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.billing_address_line2 || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Code postal</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.billing_postal_code || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Ville</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.billing_city || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Pays</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.billing_country_code || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
defineProps({
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-control-static {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: calc(1.5em + 1rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Liste des contacts</h6>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm ms-auto"
|
||||||
|
@click="contactModalIsVisible = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus me-1"></i>Ajouter un contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<contact-modal
|
||||||
|
:is-visible="contactModalIsVisible"
|
||||||
|
:client-id="clientId"
|
||||||
|
:contact-is-loading="isLoading"
|
||||||
|
@close="contactModalIsVisible = false"
|
||||||
|
@contact-created="handleContactCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="contacts.length > 0" class="table-responsive">
|
||||||
|
<table class="table align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
|
||||||
|
>
|
||||||
|
Téléphone
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
|
||||||
|
>
|
||||||
|
Poste
|
||||||
|
</th>
|
||||||
|
<th class="text-secondary opacity-7"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="contact in contacts" :key="contact.id">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex px-2 py-1">
|
||||||
|
<div class="avatar avatar-sm me-3">
|
||||||
|
<div
|
||||||
|
class="avatar-placeholder bg-gradient-info text-white d-flex align-items-center justify-content-center rounded-circle"
|
||||||
|
>
|
||||||
|
{{ getInitials(contact.full_name) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column justify-content-center">
|
||||||
|
<h6 class="mb-0 text-sm">{{ contact.full_name }}</h6>
|
||||||
|
<p
|
||||||
|
v-if="contact.is_primary"
|
||||||
|
class="text-xs text-success mb-0"
|
||||||
|
>
|
||||||
|
<i class="fas fa-star me-1"></i>Contact principal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.email || "-" }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.phone || contact.mobile || "-" }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.position || "-" }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<button class="btn btn-link text-secondary mb-0">
|
||||||
|
<i class="fa fa-ellipsis-v text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-5">
|
||||||
|
<i class="fas fa-address-book fa-3x text-secondary opacity-5 mb-3"></i>
|
||||||
|
<p class="text-sm text-secondary">Aucun contact pour ce client</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, ref } from "vue";
|
||||||
|
import ContactModal from "../contact/ContactModal.vue";
|
||||||
|
defineProps({
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
clientId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactModalIsVisible = ref(false);
|
||||||
|
|
||||||
|
const emit = defineEmits(["add-contact", "contact-created"]);
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return "?";
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContactCreated = (newContact) => {
|
||||||
|
contactModalIsVisible.value = false;
|
||||||
|
// Emit an event to notify the parent component about the new contact
|
||||||
|
emit("contact-created", newContact);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-sm .avatar-placeholder {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,674 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Informations détaillées</h6>
|
||||||
|
<button
|
||||||
|
v-if="!isEditing"
|
||||||
|
class="btn btn-primary btn-sm ms-auto"
|
||||||
|
@click="startEdit"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit me-1"></i>Modifier
|
||||||
|
</button>
|
||||||
|
<div v-else class="ms-auto">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary btn-sm me-2"
|
||||||
|
@click="cancelEdit"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times me-1"></i>Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
@click="saveChanges"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<i class="fas fa-save me-1"></i>
|
||||||
|
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="saveChanges">
|
||||||
|
<!-- Informations générales -->
|
||||||
|
<div class="info-section mb-4">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-building text-primary me-2"></i>Informations
|
||||||
|
générales
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label"
|
||||||
|
>Nom du client <span class="text-danger">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.name }"
|
||||||
|
placeholder="Nom du client"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.name }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.name" class="invalid-feedback d-block">
|
||||||
|
{{ errors.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ client.type_label || "-" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">SIRET</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.siret"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.siret }"
|
||||||
|
placeholder="SIRET"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.siret || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.siret" class="invalid-feedback d-block">
|
||||||
|
{{ errors.siret }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Numéro de TVA</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.vat_number"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.vat_number }"
|
||||||
|
placeholder="Numéro de TVA"
|
||||||
|
maxlength="32"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.vat_number || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.vat_number" class="invalid-feedback d-block">
|
||||||
|
{{ errors.vat_number }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Groupe de clients</label>
|
||||||
|
<select
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.group_id"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.group_id }"
|
||||||
|
>
|
||||||
|
<option :value="null">Aucun groupe</option>
|
||||||
|
<option
|
||||||
|
v-for="group in clientGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:value="group.id"
|
||||||
|
>
|
||||||
|
{{ group.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ getGroupName(client.group_id) || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.group_id" class="invalid-feedback d-block">
|
||||||
|
{{ errors.group_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Taux de TVA par défaut</label>
|
||||||
|
<select
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.default_tva_rate_id"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.default_tva_rate_id }"
|
||||||
|
>
|
||||||
|
<option :value="null">Taux par défaut</option>
|
||||||
|
<option
|
||||||
|
v-for="tvaRate in tvaRates"
|
||||||
|
:key="tvaRate.id"
|
||||||
|
:value="tvaRate.id"
|
||||||
|
>
|
||||||
|
{{ tvaRate.rate }}% - {{ tvaRate.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ getTvaRateName(client.default_tva_rate_id) || "-" }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="errors.default_tva_rate_id"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
|
{{ errors.default_tva_rate_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Statut</label>
|
||||||
|
<div v-if="isEditing" class="form-check form-switch mt-2">
|
||||||
|
<input
|
||||||
|
v-model="formData.is_active"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
:class="{ 'is-invalid': errors.is_active }"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label">
|
||||||
|
{{ formData.is_active ? "Actif" : "Inactif" }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
<span
|
||||||
|
:class="client.is_active ? 'text-success' : 'text-danger'"
|
||||||
|
>
|
||||||
|
{{ client.is_active ? "Actif" : "Inactif" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.is_active" class="invalid-feedback d-block">
|
||||||
|
{{ errors.is_active }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<div class="info-section mb-4">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-phone text-success me-2"></i>Contact
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.email"
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.email }"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
<a v-if="client.email" :href="`mailto:${client.email}`">
|
||||||
|
{{ client.email }}
|
||||||
|
</a>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.email" class="invalid-feedback d-block">
|
||||||
|
{{ errors.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Téléphone</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.phone"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.phone }"
|
||||||
|
placeholder="+33 1 23 45 67 89"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.phone || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.phone" class="invalid-feedback d-block">
|
||||||
|
{{ errors.phone }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Adresse de facturation -->
|
||||||
|
<div class="info-section mb-4">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-map-marker-alt text-warning me-2"></i>Adresse de
|
||||||
|
facturation
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.billing_address_line1"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.billing_address_line1 }"
|
||||||
|
placeholder="Adresse"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.billing_address_line1 || "-" }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="errors.billing_address_line1"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
|
{{ errors.billing_address_line1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.billing_address_line2"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.billing_address_line2 }"
|
||||||
|
placeholder="Complément d'adresse"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.billing_address_line2 || "-" }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="errors.billing_address_line2"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
|
{{ errors.billing_address_line2 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Code postal</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.billing_postal_code"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.billing_postal_code }"
|
||||||
|
placeholder="75001"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.billing_postal_code || "-" }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="errors.billing_postal_code"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
|
{{ errors.billing_postal_code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Ville</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.billing_city"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.billing_city }"
|
||||||
|
placeholder="Paris"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.billing_city || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.billing_city" class="invalid-feedback d-block">
|
||||||
|
{{ errors.billing_city }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Pays</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.billing_country_code"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.billing_country_code }"
|
||||||
|
placeholder="FR"
|
||||||
|
maxlength="2"
|
||||||
|
/>
|
||||||
|
<p v-else class="form-control-static text-sm">
|
||||||
|
{{ client.billing_country_code || "-" }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="errors.billing_country_code"
|
||||||
|
class="invalid-feedback d-block"
|
||||||
|
>
|
||||||
|
{{ errors.billing_country_code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="info-section">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-sticky-note text-info me-2"></i>Notes
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<label class="form-label">Notes internes</label>
|
||||||
|
<textarea
|
||||||
|
v-if="isEditing"
|
||||||
|
v-model="formData.notes"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.notes }"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Notes..."
|
||||||
|
></textarea>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="form-control-static text-sm"
|
||||||
|
style="white-space: pre-wrap"
|
||||||
|
>
|
||||||
|
{{ client.notes || "-" }}
|
||||||
|
</p>
|
||||||
|
<div v-if="errors.notes" class="invalid-feedback d-block">
|
||||||
|
{{ errors.notes }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from "vue";
|
||||||
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
const props = defineProps({
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
clientGroups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
tvaRates: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientStore = useClientStore();
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const errors = reactive({});
|
||||||
|
const formData = reactive({
|
||||||
|
name: "",
|
||||||
|
vat_number: "",
|
||||||
|
siret: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
billing_address_line1: "",
|
||||||
|
billing_address_line2: "",
|
||||||
|
billing_postal_code: "",
|
||||||
|
billing_city: "",
|
||||||
|
billing_country_code: "",
|
||||||
|
group_id: null,
|
||||||
|
notes: "",
|
||||||
|
is_active: true,
|
||||||
|
default_tva_rate_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
isEditing.value = true;
|
||||||
|
Object.assign(formData, {
|
||||||
|
name: props.client.name || "",
|
||||||
|
vat_number: props.client.vat_number || "",
|
||||||
|
siret: props.client.siret || "",
|
||||||
|
email: props.client.email || "",
|
||||||
|
phone: props.client.phone || "",
|
||||||
|
billing_address_line1: props.client.billing_address_line1 || "",
|
||||||
|
billing_address_line2: props.client.billing_address_line2 || "",
|
||||||
|
billing_postal_code: props.client.billing_postal_code || "",
|
||||||
|
billing_city: props.client.billing_city || "",
|
||||||
|
billing_country_code: props.client.billing_country_code || "FR", // Valeur par défaut
|
||||||
|
group_id: props.client.group_id || null,
|
||||||
|
notes: props.client.notes || "",
|
||||||
|
is_active:
|
||||||
|
props.client.is_active !== undefined ? props.client.is_active : true,
|
||||||
|
default_tva_rate_id: props.client.default_tva_rate_id || null,
|
||||||
|
});
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
isEditing.value = false;
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupName = (groupId) => {
|
||||||
|
const group = props.clientGroups.find((g) => g.id === groupId);
|
||||||
|
return group ? group.name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTvaRateName = (tvaRateId) => {
|
||||||
|
const tvaRate = props.tvaRates.find((t) => t.id === tvaRateId);
|
||||||
|
return tvaRate ? `${tvaRate.rate}% - ${tvaRate.name}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Name validation
|
||||||
|
if (!formData.name || formData.name.trim() === "") {
|
||||||
|
errors.name = "Le nom du client est obligatoire.";
|
||||||
|
isValid = false;
|
||||||
|
} else if (formData.name.length > 255) {
|
||||||
|
errors.name = "Le nom du client ne peut pas dépasser 255 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAT number validation
|
||||||
|
if (formData.vat_number && formData.vat_number.length > 32) {
|
||||||
|
errors.vat_number = "Le numéro de TVA ne peut pas dépasser 32 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIRET validation
|
||||||
|
if (formData.siret && formData.siret.length > 20) {
|
||||||
|
errors.siret = "Le SIRET ne peut pas dépasser 20 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
errors.email = "L'adresse email doit être valide.";
|
||||||
|
isValid = false;
|
||||||
|
} else if (formData.email.length > 191) {
|
||||||
|
errors.email = "L'adresse email ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation
|
||||||
|
if (formData.phone && formData.phone.length > 50) {
|
||||||
|
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Billing address validations
|
||||||
|
if (
|
||||||
|
formData.billing_address_line1 &&
|
||||||
|
formData.billing_address_line1.length > 255
|
||||||
|
) {
|
||||||
|
errors.billing_address_line1 =
|
||||||
|
"L'adresse ne peut pas dépasser 255 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
formData.billing_address_line2 &&
|
||||||
|
formData.billing_address_line2.length > 255
|
||||||
|
) {
|
||||||
|
errors.billing_address_line2 =
|
||||||
|
"Le complément d'adresse ne peut pas dépasser 255 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
formData.billing_postal_code &&
|
||||||
|
formData.billing_postal_code.length > 20
|
||||||
|
) {
|
||||||
|
errors.billing_postal_code =
|
||||||
|
"Le code postal ne peut pas dépasser 20 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.billing_city && formData.billing_city.length > 191) {
|
||||||
|
errors.billing_city = "La ville ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation corrigée pour billing_country_code
|
||||||
|
if (formData.billing_country_code) {
|
||||||
|
if (formData.billing_country_code.length !== 2) {
|
||||||
|
errors.billing_country_code = "Le code pays doit contenir 2 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Si vide, on met une valeur par défaut pour éviter l'erreur SQL
|
||||||
|
formData.billing_country_code = "FR";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group validation
|
||||||
|
if (
|
||||||
|
formData.group_id &&
|
||||||
|
!props.clientGroups.find((g) => g.id === formData.group_id)
|
||||||
|
) {
|
||||||
|
errors.group_id = "Le groupe de clients sélectionné n'existe pas.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TVA rate validation
|
||||||
|
if (
|
||||||
|
formData.default_tva_rate_id &&
|
||||||
|
!props.tvaRates.find((t) => t.id === formData.default_tva_rate_id)
|
||||||
|
) {
|
||||||
|
errors.default_tva_rate_id = "Le taux de TVA sélectionné n'existe pas.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active status validation
|
||||||
|
if (typeof formData.is_active !== "boolean") {
|
||||||
|
errors.is_active = "Le statut actif doit être vrai ou faux.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareFormData = () => {
|
||||||
|
// Nettoyer les données avant envoi
|
||||||
|
const cleanedData = { ...formData };
|
||||||
|
|
||||||
|
// Convertir les chaînes vides en null pour les champs nullable
|
||||||
|
const nullableFields = [
|
||||||
|
"vat_number",
|
||||||
|
"siret",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"billing_address_line1",
|
||||||
|
"billing_address_line2",
|
||||||
|
"billing_postal_code",
|
||||||
|
"billing_city",
|
||||||
|
"notes",
|
||||||
|
"group_id",
|
||||||
|
"default_tva_rate_id",
|
||||||
|
];
|
||||||
|
|
||||||
|
nullableFields.forEach((field) => {
|
||||||
|
if (cleanedData[field] === "") {
|
||||||
|
cleanedData[field] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// S'assurer que billing_country_code a une valeur
|
||||||
|
if (
|
||||||
|
!cleanedData.billing_country_code ||
|
||||||
|
cleanedData.billing_country_code === ""
|
||||||
|
) {
|
||||||
|
cleanedData.billing_country_code = "FR";
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedData;
|
||||||
|
};
|
||||||
|
const saveChanges = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isEditing.value = false;
|
||||||
|
emit("client-updated", formData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la mise à jour:", error);
|
||||||
|
if (error.response && error.response.data && error.response.data.errors) {
|
||||||
|
Object.assign(errors, error.response.data.errors);
|
||||||
|
} else {
|
||||||
|
errors.general = "Une erreur est survenue lors de la sauvegarde.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Émettre les événements
|
||||||
|
const emit = defineEmits(["client-updated"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-control-static {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: calc(1.5em + 1rem);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #cb0c9f;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-feedback {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: #cb0c9f;
|
||||||
|
border-color: #cb0c9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: #198754 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #dc3545 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #cb0c9f;
|
||||||
|
border-color: #cb0c9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #a90982;
|
||||||
|
border-color: #a90982;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Notes</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p v-if="notes" class="text-sm">
|
||||||
|
{{ notes }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-secondary">Aucune note pour ce client</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
defineProps({
|
||||||
|
notes: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Aperçu du client</h6>
|
||||||
|
<!-- <router-link
|
||||||
|
:to="`/clients/${clientId}/edit`"
|
||||||
|
class="btn btn-primary btn-sm ms-auto"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit me-1"></i>Modifier
|
||||||
|
</router-link> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Contact Info Card -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<InfoCard title="Contact" icon="fas fa-phone text-primary">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
|
||||||
|
<strong class="text-dark">Email:</strong>
|
||||||
|
<a :href="`mailto:${client.email}`" class="ms-2">
|
||||||
|
{{ client.email || "-" }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item border-0 ps-0 text-sm">
|
||||||
|
<strong class="text-dark">Téléphone:</strong>
|
||||||
|
<span class="ms-2">{{ client.phone || "-" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Business Info Card -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<InfoCard title="Entreprise" icon="fas fa-building text-warning">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
|
||||||
|
<strong class="text-dark">SIRET:</strong>
|
||||||
|
<span class="ms-2">{{ client.siret || "-" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item border-0 ps-0 text-sm">
|
||||||
|
<strong class="text-dark">TVA:</strong>
|
||||||
|
<span class="ms-2">{{ client.vat_number || "-" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Card (Full Width) -->
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<InfoCard title="Adresse" icon="fas fa-map-marked-alt text-success">
|
||||||
|
<p class="text-sm mb-0">
|
||||||
|
{{ formattedAddress }}
|
||||||
|
</p>
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Contacts -->
|
||||||
|
<div class="col-12">
|
||||||
|
<InfoCard
|
||||||
|
title="Contacts récents"
|
||||||
|
icon="fas fa-address-book text-info"
|
||||||
|
>
|
||||||
|
<template v-if="contacts.length > 0">
|
||||||
|
<div class="d-flex align-items-center mb-3 justify-content-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
@click="$emit('view-all-contacts')"
|
||||||
|
>
|
||||||
|
Voir tous
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="contact in contacts.slice(0, 3)"
|
||||||
|
:key="contact.id"
|
||||||
|
class="d-flex align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div class="avatar avatar-sm me-3">
|
||||||
|
<div
|
||||||
|
class="avatar-placeholder bg-gradient-secondary text-white d-flex align-items-center justify-content-center rounded-circle"
|
||||||
|
>
|
||||||
|
{{ getInitials(contact.full_name) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-0 text-sm">{{ contact.full_name }}</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">
|
||||||
|
{{ contact.position || "Contact" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="contact.is_primary"
|
||||||
|
status="success"
|
||||||
|
label="Principal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-sm text-secondary mb-0">
|
||||||
|
Aucun contact enregistré
|
||||||
|
</p>
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import InfoCard from "@/components/atoms/client/InfoCard.vue";
|
||||||
|
import StatusBadge from "@/components/atoms/client/StatusBadge.vue";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
formattedAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "Aucune adresse renseignée",
|
||||||
|
},
|
||||||
|
clientId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["view-all-contacts"]);
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return "?";
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-sm .avatar-placeholder {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<!-- Client Avatar -->
|
||||||
|
<ClientAvatar
|
||||||
|
:avatar-url="avatarUrl"
|
||||||
|
:initials="initials"
|
||||||
|
:alt="clientName"
|
||||||
|
:editable="true"
|
||||||
|
@edit="$emit('edit-avatar')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Client Name -->
|
||||||
|
<h5 class="font-weight-bolder mb-0">
|
||||||
|
{{ clientName }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-sm text-secondary mb-3">
|
||||||
|
{{ clientType }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="row text-center mt-3">
|
||||||
|
<div class="col-6 border-end">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
{{ contactsCount }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Contacts</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
<i
|
||||||
|
class="fas"
|
||||||
|
:class="
|
||||||
|
isActive
|
||||||
|
? 'fa-check-circle text-success'
|
||||||
|
: 'fa-times-circle text-danger'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Statut</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ClientAvatar from "@/components/atoms/client/ClientAvatar.vue";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
avatarUrl: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initials: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
clientType: {
|
||||||
|
type: String,
|
||||||
|
default: "Client",
|
||||||
|
},
|
||||||
|
contactsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["edit-avatar"]);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<ul class="nav nav-pills flex-column">
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-eye"
|
||||||
|
label="Aperçu"
|
||||||
|
:is-active="activeTab === 'overview'"
|
||||||
|
spacing=""
|
||||||
|
@click="$emit('change-tab', 'overview')"
|
||||||
|
/>
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-info-circle"
|
||||||
|
label="Informations"
|
||||||
|
:is-active="activeTab === 'info'"
|
||||||
|
@click="$emit('change-tab', 'info')"
|
||||||
|
/>
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-users"
|
||||||
|
label="Contacts"
|
||||||
|
:is-active="activeTab === 'contacts'"
|
||||||
|
:badge="contactsCount > 0 ? contactsCount : null"
|
||||||
|
@click="$emit('change-tab', 'contacts')"
|
||||||
|
/>
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-map-marker-alt"
|
||||||
|
label="Adresse"
|
||||||
|
:is-active="activeTab === 'address'"
|
||||||
|
@click="$emit('change-tab', 'address')"
|
||||||
|
/>
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-sticky-note"
|
||||||
|
label="Notes"
|
||||||
|
:is-active="activeTab === 'notes'"
|
||||||
|
@click="$emit('change-tab', 'notes')"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
|
||||||
|
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
defineProps({
|
||||||
|
activeTab: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contactsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["change-tab"]);
|
||||||
|
</script>
|
||||||
@ -1,35 +1,193 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card contacts-container">
|
||||||
<div class="p-3 pb-0 card-header">
|
<div
|
||||||
|
class="p-2 card-header d-flex justify-content-between align-items-center"
|
||||||
|
>
|
||||||
<h6 class="mb-0">Contacts</h6>
|
<h6 class="mb-0">Contacts</h6>
|
||||||
</div>
|
<button
|
||||||
<div class="p-3 card-body border-radius-lg">
|
class="btn btn-primary btn-sm"
|
||||||
<div
|
@click="addContact"
|
||||||
v-for="contact of contacts"
|
title="Add contact"
|
||||||
:key="contact.id"
|
|
||||||
class="d-flex"
|
|
||||||
:class="index !== 0 ? 'mt-4' : ''"
|
|
||||||
>
|
>
|
||||||
<div class="ms-3">
|
<i class="fas fa-plus"></i>
|
||||||
<div class="numbers">
|
</button>
|
||||||
<h6 class="mb-1 text-sm text-dark">
|
</div>
|
||||||
{{ contact.first_name + " " + contact.first_name }}
|
<div class="p-2 card-body">
|
||||||
</h6>
|
<div
|
||||||
<span class="text-sm">{{ email }}</span>
|
v-for="(contact, index) of contacts"
|
||||||
<span class="text-sm">{{ phone }}</span>
|
:key="contact.id"
|
||||||
|
class="contact-item d-flex align-items-center justify-content-between"
|
||||||
|
:class="{
|
||||||
|
'contact-item-even': index % 2 === 0,
|
||||||
|
'contact-item-odd': index % 2 !== 0,
|
||||||
|
'mt-2': index !== 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-items-center flex-grow-1">
|
||||||
|
<div class="contact-avatar">
|
||||||
|
{{ getInitials(contact.first_name, contact.last_name) }}
|
||||||
|
</div>
|
||||||
|
<div class="ms-2 flex-grow-1">
|
||||||
|
<div class="contact-info">
|
||||||
|
<h6 class="mb-0 text-dark contact-name">
|
||||||
|
{{ contact.first_name }} {{ contact.last_name }}
|
||||||
|
</h6>
|
||||||
|
<div class="contact-details">
|
||||||
|
<small class="text-muted contact-email d-block">
|
||||||
|
{{ contact.email }}
|
||||||
|
</small>
|
||||||
|
<small class="text-muted contact-phone">
|
||||||
|
{{ contact.phone }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-danger btn-sm delete-btn ms-2"
|
||||||
|
@click="deleteContact(contact.id)"
|
||||||
|
title="Delete contact"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="contacts.length === 0" class="text-center py-3 empty-state">
|
||||||
|
<i class="fas fa-address-book mb-1 text-muted"></i>
|
||||||
|
<p class="text-muted mb-0">No contacts</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from "vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
defineProps({
|
|
||||||
|
const props = defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
type: Object,
|
type: Array,
|
||||||
default: [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["add-contact", "delete-contact"]);
|
||||||
|
|
||||||
|
const addContact = () => {
|
||||||
|
emit("add-contact");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteContact = (contactId) => {
|
||||||
|
emit("delete-contact", contactId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (firstName, lastName) => {
|
||||||
|
return `${firstName?.charAt(0) || ""}${
|
||||||
|
lastName?.charAt(0) || ""
|
||||||
|
}`.toUpperCase();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contacts-container {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item-even {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 3px solid #4e73df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item-odd {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-left: 3px solid #1cc88a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-email,
|
||||||
|
.contact-phone {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: #e74a3b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #4e73df;
|
||||||
|
border-color: #4e73df;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2e59d9;
|
||||||
|
border-color: #2e59d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Modal Component -->
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
:class="{ show: isVisible, 'd-block': isVisible }"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
:aria-hidden="!isVisible"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
|
Ajouter un contact
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
@click="closeModal"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<!-- Client ID (hidden) -->
|
||||||
|
<input type="hidden" v-model="formData.client_id" />
|
||||||
|
|
||||||
|
<!-- First Name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Prénom</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.first_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.first_name }"
|
||||||
|
placeholder="Prénom du contact"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<div v-if="errors.first_name" class="invalid-feedback">
|
||||||
|
{{ errors.first_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nom</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.last_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.last_name }"
|
||||||
|
placeholder="Nom du contact"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<div v-if="errors.last_name" class="invalid-feedback">
|
||||||
|
{{ errors.last_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.email"
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.email }"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<div v-if="errors.email" class="invalid-feedback">
|
||||||
|
{{ errors.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Téléphone</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.phone"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.phone }"
|
||||||
|
placeholder="+33 1 23 45 67 89"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<div v-if="errors.phone" class="invalid-feedback">
|
||||||
|
{{ errors.phone }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rôle</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.role"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.role }"
|
||||||
|
placeholder="Rôle dans l'entreprise"
|
||||||
|
maxlength="191"
|
||||||
|
/>
|
||||||
|
<div v-if="errors.role" class="invalid-feedback">
|
||||||
|
{{ errors.role }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General Error -->
|
||||||
|
<div v-if="errors.general" class="alert alert-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
{{ errors.general }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
@click="closeModal"
|
||||||
|
:disabled="contactIsLoading"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times me-1"></i>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="submitForm"
|
||||||
|
:disabled="contactIsLoading"
|
||||||
|
>
|
||||||
|
<i class="fas fa-save me-1"></i>
|
||||||
|
{{ contactIsLoading ? "Création..." : "Créer le contact" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="modal-backdrop fade show"
|
||||||
|
@click="closeModal"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from "vue";
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
clientId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contactIsLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(["close", "contact-created"]);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const errors = reactive({});
|
||||||
|
const formData = reactive({
|
||||||
|
client_id: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
role: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for clientId changes
|
||||||
|
watch(
|
||||||
|
() => props.clientId,
|
||||||
|
(newVal) => {
|
||||||
|
formData.client_id = newVal;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const closeModal = () => {
|
||||||
|
resetForm();
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.keys(formData).forEach((key) => {
|
||||||
|
if (key !== "client_id") {
|
||||||
|
formData[key] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
// Clear previous errors
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||||
|
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Client ID validation
|
||||||
|
if (!formData.client_id) {
|
||||||
|
errors.client_id = "Le client est obligatoire.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First name validation
|
||||||
|
if (formData.first_name && formData.first_name.length > 191) {
|
||||||
|
errors.first_name = "Le prénom ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last name validation
|
||||||
|
if (formData.last_name && formData.last_name.length > 191) {
|
||||||
|
errors.last_name = "Le nom ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
errors.email = "L'adresse email doit être valide.";
|
||||||
|
isValid = false;
|
||||||
|
} else if (formData.email.length > 191) {
|
||||||
|
errors.email = "L'adresse email ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation
|
||||||
|
if (formData.phone && formData.phone.length > 50) {
|
||||||
|
errors.phone = "Le téléphone ne peut pas dépasser 50 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role validation
|
||||||
|
if (formData.role && formData.role.length > 191) {
|
||||||
|
errors.role = "Le rôle ne peut pas dépasser 191 caractères.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one field validation
|
||||||
|
const hasAtLeastOneField =
|
||||||
|
formData.first_name ||
|
||||||
|
formData.last_name ||
|
||||||
|
formData.email ||
|
||||||
|
formData.phone;
|
||||||
|
|
||||||
|
if (!hasAtLeastOneField) {
|
||||||
|
errors.general =
|
||||||
|
"Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare data for API
|
||||||
|
const submitData = { ...formData };
|
||||||
|
|
||||||
|
// Convert empty strings to null for nullable fields
|
||||||
|
const nullableFields = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"role",
|
||||||
|
];
|
||||||
|
nullableFields.forEach((field) => {
|
||||||
|
if (submitData[field] === "") {
|
||||||
|
submitData[field] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit event - parent will handle the API call and loading state
|
||||||
|
emit("contact-created", submitData);
|
||||||
|
|
||||||
|
// Close modal and reset form
|
||||||
|
closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création du contact:", error);
|
||||||
|
|
||||||
|
// Handle API errors
|
||||||
|
if (error.response && error.response.data && error.response.data.errors) {
|
||||||
|
Object.assign(errors, error.response.data.errors);
|
||||||
|
} else {
|
||||||
|
errors.general =
|
||||||
|
"Une erreur est survenue lors de la création du contact.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard event listener for ESC key
|
||||||
|
const handleKeydown = (event) => {
|
||||||
|
if (event.key === "Escape" && props.isVisible) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listener when component is mounted
|
||||||
|
import { onMounted, onUnmounted } from "vue";
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border: 1px solid #dce1e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #cb0c9f;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #cb0c9f;
|
||||||
|
border-color: #cb0c9f;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #a90982;
|
||||||
|
border-color: #a90982;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-feedback {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade {
|
||||||
|
transition: opacity 0.15s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,13 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid py-4">
|
||||||
<div class="py-4 container fluid">
|
<div class="row mb-4">
|
||||||
<div class="mt-3 row">
|
<slot name="button-return" />
|
||||||
<div class="col-12 col-md-6 col-xl-4 mt-md-0">
|
</div>
|
||||||
<slot name="client-information" />
|
<div class="row">
|
||||||
</div>
|
<div class="col-lg-3">
|
||||||
<div class="col-12 col-md-3 col-xl-r mt-md-0">
|
<slot name="client-detail-sidebar" />
|
||||||
<slot name="contact-list" />
|
<slot name="file-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg-9 mt-lg-0 mt-4">
|
||||||
|
<slot name="client-detail-content" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
UpdateClientPayload,
|
UpdateClientPayload,
|
||||||
ClientListResponse,
|
ClientListResponse,
|
||||||
} from "@/services/client";
|
} from "@/services/client";
|
||||||
|
import { Contact } from "@/services/contact";
|
||||||
|
|
||||||
export const useClientStore = defineStore("client", () => {
|
export const useClientStore = defineStore("client", () => {
|
||||||
// State
|
// State
|
||||||
@ -16,6 +17,7 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const searchResults = ref<Client[]>([]);
|
const searchResults = ref<Client[]>([]);
|
||||||
|
const contacts_client = ref<Contact[]>([]);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@ -66,6 +68,10 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
searchResults.value = searchClient;
|
searchResults.value = searchClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setContactClient = (contactClient: Contact[]) => {
|
||||||
|
contacts_client.value = contactClient;
|
||||||
|
};
|
||||||
|
|
||||||
const setPagination = (meta: any) => {
|
const setPagination = (meta: any) => {
|
||||||
if (meta) {
|
if (meta) {
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
@ -159,6 +165,7 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(payload);
|
||||||
const response = await ClientService.updateClient(payload);
|
const response = await ClientService.updateClient(payload);
|
||||||
const updatedClient = response.data;
|
const updatedClient = response.data;
|
||||||
|
|
||||||
|
|||||||
@ -26,25 +26,25 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const allContacts = computed(() => contacts.value);
|
const allContacts = computed(() => contacts.value);
|
||||||
|
|
||||||
const primaryContacts = computed(() =>
|
const primaryContacts = computed(() =>
|
||||||
contacts.value.filter((contact) => contact.is_primary)
|
contacts.value.filter((contact) => contact.is_primary)
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondaryContacts = computed(() =>
|
const secondaryContacts = computed(() =>
|
||||||
contacts.value.filter((contact) => !contact.is_primary)
|
contacts.value.filter((contact) => !contact.is_primary)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = computed(() => loading.value);
|
const isLoading = computed(() => loading.value);
|
||||||
const hasError = computed(() => error.value !== null);
|
const hasError = computed(() => error.value !== null);
|
||||||
const getError = computed(() => error.value);
|
const getError = computed(() => error.value);
|
||||||
|
|
||||||
const getContactById = computed(() => (id: number) =>
|
const getContactById = computed(() => (id: number) =>
|
||||||
contacts.value.find((contact) => contact.id === id)
|
contacts.value.find((contact) => contact.id === id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const getPagination = computed(() => pagination.value);
|
const getPagination = computed(() => pagination.value);
|
||||||
|
|
||||||
const getContactFullName = computed(() => (contact: Contact) =>
|
const getContactFullName = computed(() => (contact: Contact) =>
|
||||||
ContactService.getFullName(contact)
|
ContactService.getFullName(contact)
|
||||||
);
|
);
|
||||||
@ -102,7 +102,9 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec du chargement des contacts";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec du chargement des contacts";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -123,7 +125,9 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec du chargement du contact";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec du chargement du contact";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -146,7 +150,9 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec de la création du contact";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec de la création du contact";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -174,14 +180,19 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour le contact actuel s'il s'agit de celui en cours d'édition
|
// Mettre à jour le contact actuel s'il s'agit de celui en cours d'édition
|
||||||
if (currentContact.value && currentContact.value.id === updatedContact.id) {
|
if (
|
||||||
|
currentContact.value &&
|
||||||
|
currentContact.value.id === updatedContact.id
|
||||||
|
) {
|
||||||
setCurrentContact(updatedContact);
|
setCurrentContact(updatedContact);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedContact;
|
return updatedContact;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec de la mise à jour du contact";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec de la mise à jour du contact";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -210,7 +221,9 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec de la suppression du contact";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec de la suppression du contact";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -240,7 +253,9 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.message || err.message || "Échec de la recherche de contacts";
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec de la recherche de contacts";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -323,7 +338,10 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ContactService.getContactsByClient(clientId, params);
|
const response = await ContactService.getContactsByClient(
|
||||||
|
clientId,
|
||||||
|
params
|
||||||
|
);
|
||||||
setContacts(response.data);
|
setContacts(response.data);
|
||||||
if (response.meta) {
|
if (response.meta) {
|
||||||
setPagination(response.meta);
|
setPagination(response.meta);
|
||||||
@ -341,6 +359,28 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client list contact
|
||||||
|
*/
|
||||||
|
|
||||||
|
const getClientListContact = async (clientId: number): Promise<Contact[]> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await ContactService.getContactsByClient(clientId);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
"Échec du chargement des contacts du client";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Réinitialiser l'état
|
* Réinitialiser l'état
|
||||||
*/
|
*/
|
||||||
@ -393,6 +433,7 @@ export const useContactStore = defineStore("contact", () => {
|
|||||||
setPrimaryContact,
|
setPrimaryContact,
|
||||||
fetchContactsByClient,
|
fetchContactsByClient,
|
||||||
resetState,
|
resetState,
|
||||||
|
getClientListContact,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
578
thanasoft-front/src/views/pages/CRM/ClientDetailNew.vue
Normal file
578
thanasoft-front/src/views/pages/CRM/ClientDetailNew.vue
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Header with Back Button -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<router-link
|
||||||
|
to="/clients"
|
||||||
|
class="btn btn-outline-secondary btn-sm mb-3"
|
||||||
|
>
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Retour aux clients
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="clientStore.isLoading" class="text-center p-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="clientStore.hasError" class="alert alert-danger m-3">
|
||||||
|
{{ clientStore.getError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div v-else-if="clientStore.currentClient" class="row">
|
||||||
|
<!-- Left Sidebar - Client Card & Navigation -->
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<!-- Client Profile Card -->
|
||||||
|
<div class="card position-sticky top-1">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<!-- Client Avatar/Logo -->
|
||||||
|
<div class="avatar avatar-xxl position-relative mx-auto mb-3">
|
||||||
|
<img
|
||||||
|
v-if="clientAvatar"
|
||||||
|
:src="clientAvatar"
|
||||||
|
alt="client logo"
|
||||||
|
class="w-100 border-radius-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="avatar-placeholder w-100 border-radius-lg shadow-sm d-flex align-items-center justify-content-center bg-gradient-primary"
|
||||||
|
>
|
||||||
|
<span class="text-white text-lg font-weight-bold">
|
||||||
|
{{ getInitials(clientStore.currentClient.name) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Edit Avatar Button -->
|
||||||
|
<a
|
||||||
|
href="javascript:;"
|
||||||
|
class="btn btn-sm btn-icon-only bg-gradient-light position-absolute bottom-0 end-0 mb-n2 me-n2"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
>
|
||||||
|
<i class="fa fa-pen top-0" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit Image"></i>
|
||||||
|
</a>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="d-none"
|
||||||
|
accept="image/*"
|
||||||
|
@change="handleAvatarUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Name -->
|
||||||
|
<h5 class="font-weight-bolder mb-0">
|
||||||
|
{{ clientStore.currentClient.name }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-sm text-secondary mb-3">
|
||||||
|
{{ clientStore.currentClient.type_label || 'Client' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="row text-center mt-3">
|
||||||
|
<div class="col-6 border-end">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
{{ contacts_client.length }}
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Contacts</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h6 class="text-sm font-weight-bolder mb-0">
|
||||||
|
<i
|
||||||
|
class="fas"
|
||||||
|
:class="clientStore.currentClient.is_active ? 'fa-check-circle text-success' : 'fa-times-circle text-danger'"
|
||||||
|
></i>
|
||||||
|
</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">Statut</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="horizontal dark my-3" />
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<ul class="nav nav-pills flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'overview' }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="activeTab = 'overview'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye me-2"></i>
|
||||||
|
<span class="text-sm">Aperçu</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item pt-2">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'info' }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="activeTab = 'info'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
<span class="text-sm">Informations</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item pt-2">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'contacts' }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="activeTab = 'contacts'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-users me-2"></i>
|
||||||
|
<span class="text-sm">Contacts</span>
|
||||||
|
<span v-if="contacts_client.length > 0" class="badge badge-sm bg-gradient-success ms-auto">
|
||||||
|
{{ contacts_client.length }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item pt-2">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'address' }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="activeTab = 'address'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-map-marker-alt me-2"></i>
|
||||||
|
<span class="text-sm">Adresse</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item pt-2">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: activeTab === 'notes' }"
|
||||||
|
href="javascript:;"
|
||||||
|
@click="activeTab = 'notes'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sticky-note me-2"></i>
|
||||||
|
<span class="text-sm">Notes</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Content Area -->
|
||||||
|
<div class="col-lg-9 mt-lg-0 mt-4">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div v-show="activeTab === 'overview'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Aperçu du client</h6>
|
||||||
|
<router-link
|
||||||
|
:to="`/clients/${clientStore.currentClient.id}/edit`"
|
||||||
|
class="btn btn-primary btn-sm ms-auto"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit me-1"></i>Modifier
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Contact Info Card -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card shadow-none border">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-phone text-primary me-2"></i>Contact
|
||||||
|
</h6>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
|
||||||
|
<strong class="text-dark">Email:</strong>
|
||||||
|
<a :href="`mailto:${clientStore.currentClient.email}`" class="ms-2">
|
||||||
|
{{ clientStore.currentClient.email || '-' }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item border-0 ps-0 text-sm">
|
||||||
|
<strong class="text-dark">Téléphone:</strong>
|
||||||
|
<span class="ms-2">{{ clientStore.currentClient.phone || '-' }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Business Info Card -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card shadow-none border">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-building text-warning me-2"></i>Entreprise
|
||||||
|
</h6>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item border-0 ps-0 pt-0 text-sm">
|
||||||
|
<strong class="text-dark">SIRET:</strong>
|
||||||
|
<span class="ms-2">{{ clientStore.currentClient.siret || '-' }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item border-0 ps-0 text-sm">
|
||||||
|
<strong class="text-dark">TVA:</strong>
|
||||||
|
<span class="ms-2">{{ clientStore.currentClient.vat_number || '-' }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Card (Full Width) -->
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card shadow-none border">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-sm text-uppercase text-body font-weight-bolder mb-3">
|
||||||
|
<i class="fas fa-map-marked-alt text-success me-2"></i>Adresse
|
||||||
|
</h6>
|
||||||
|
<p class="text-sm mb-0">
|
||||||
|
{{ formatAddress(clientStore.currentClient) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Contacts -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-none border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<h6 class="mb-0 text-sm text-uppercase text-body font-weight-bolder">
|
||||||
|
<i class="fas fa-address-book text-info me-2"></i>Contacts récents
|
||||||
|
</h6>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary ms-auto"
|
||||||
|
@click="activeTab = 'contacts'"
|
||||||
|
>
|
||||||
|
Voir tous
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="contacts_client.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="contact in contacts_client.slice(0, 3)"
|
||||||
|
:key="contact.id"
|
||||||
|
class="d-flex align-items-center mb-2"
|
||||||
|
>
|
||||||
|
<div class="avatar avatar-sm me-3">
|
||||||
|
<div class="avatar-placeholder bg-gradient-secondary text-white d-flex align-items-center justify-content-center rounded-circle">
|
||||||
|
{{ getInitials(contact.full_name) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-0 text-sm">{{ contact.full_name }}</h6>
|
||||||
|
<p class="text-xs text-secondary mb-0">{{ contact.position || 'Contact' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span v-if="contact.is_primary" class="badge badge-sm bg-gradient-success">
|
||||||
|
Principal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-secondary mb-0">
|
||||||
|
Aucun contact enregistré
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information Tab -->
|
||||||
|
<div v-show="activeTab === 'info'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Informations détaillées</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Nom du client</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.type_label || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<label class="form-label">SIRET</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.siret || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<label class="form-label">Numéro de TVA</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.vat_number || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
<a :href="`mailto:${clientStore.currentClient.email}`">
|
||||||
|
{{ clientStore.currentClient.email || '-' }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<label class="form-label">Téléphone</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.phone || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-3">
|
||||||
|
<label class="form-label">Commercial</label>
|
||||||
|
<p class="form-control-static text-sm">{{ clientStore.currentClient.commercial || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts Tab -->
|
||||||
|
<div v-show="activeTab === 'contacts'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Liste des contacts</h6>
|
||||||
|
<button class="btn btn-primary btn-sm ms-auto">
|
||||||
|
<i class="fas fa-plus me-1"></i>Ajouter un contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="contacts_client.length > 0" class="table-responsive">
|
||||||
|
<table class="table align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">
|
||||||
|
Contact
|
||||||
|
</th>
|
||||||
|
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">
|
||||||
|
Téléphone
|
||||||
|
</th>
|
||||||
|
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2">
|
||||||
|
Poste
|
||||||
|
</th>
|
||||||
|
<th class="text-secondary opacity-7"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="contact in contacts_client" :key="contact.id">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex px-2 py-1">
|
||||||
|
<div class="avatar avatar-sm me-3">
|
||||||
|
<div class="avatar-placeholder bg-gradient-info text-white d-flex align-items-center justify-content-center rounded-circle">
|
||||||
|
{{ getInitials(contact.full_name) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column justify-content-center">
|
||||||
|
<h6 class="mb-0 text-sm">{{ contact.full_name }}</h6>
|
||||||
|
<p v-if="contact.is_primary" class="text-xs text-success mb-0">
|
||||||
|
<i class="fas fa-star me-1"></i>Contact principal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.email || '-' }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.phone || contact.mobile || '-' }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-xs font-weight-bold mb-0">
|
||||||
|
{{ contact.position || '-' }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<button class="btn btn-link text-secondary mb-0">
|
||||||
|
<i class="fa fa-ellipsis-v text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-5">
|
||||||
|
<i class="fas fa-address-book fa-3x text-secondary opacity-5 mb-3"></i>
|
||||||
|
<p class="text-sm text-secondary">Aucun contact pour ce client</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Tab -->
|
||||||
|
<div v-show="activeTab === 'address'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Adresse de facturation</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ clientStore.currentClient.billing_address_line1 || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-3">
|
||||||
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ clientStore.currentClient.billing_address_line2 || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Code postal</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ clientStore.currentClient.billing_postal_code || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Ville</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ clientStore.currentClient.billing_city || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-3">
|
||||||
|
<label class="form-label">Pays</label>
|
||||||
|
<p class="form-control-static text-sm">
|
||||||
|
{{ clientStore.currentClient.billing_country_code || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes Tab -->
|
||||||
|
<div v-show="activeTab === 'notes'" class="card">
|
||||||
|
<div class="card-header pb-0">
|
||||||
|
<h6 class="mb-0">Notes</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p v-if="clientStore.currentClient.notes" class="text-sm">
|
||||||
|
{{ clientStore.currentClient.notes }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-secondary">
|
||||||
|
Aucune note pour ce client
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useClientStore } from '@/stores/clientStore';
|
||||||
|
import { useContactStore } from '@/stores/contactStore';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const clientStore = useClientStore();
|
||||||
|
const contactStore = useContactStore();
|
||||||
|
|
||||||
|
const client_id = route.params.id;
|
||||||
|
const contacts_client = ref([]);
|
||||||
|
const activeTab = ref('overview');
|
||||||
|
const clientAvatar = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (client_id) {
|
||||||
|
await clientStore.fetchClient(Number(client_id));
|
||||||
|
contacts_client.value = await contactStore.getClientListContact(Number(client_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return '?';
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAddress = (client) => {
|
||||||
|
const parts = [
|
||||||
|
client.billing_address_line1,
|
||||||
|
client.billing_address_line2,
|
||||||
|
client.billing_postal_code,
|
||||||
|
client.billing_city,
|
||||||
|
client.billing_country_code,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(', ') : 'Aucune adresse renseignée';
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInput.value.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
clientAvatar.value = e.target.result;
|
||||||
|
// TODO: Upload to server
|
||||||
|
console.log('Upload avatar to server');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-sm .avatar-placeholder {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: #67748e;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link.active {
|
||||||
|
background: linear-gradient(310deg, #7928ca, #ff0080);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.09), 0 2px 3px -1px rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-link i {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-static {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: calc(1.5em + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-sticky {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,43 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<client-detail-presentation
|
<client-detail-presentation
|
||||||
v-if="clientStore.currentClient"
|
v-if="clientStore.currentClient"
|
||||||
:client="clientStore.currentClient"
|
:client="clientStore.currentClient"
|
||||||
@update:client="handleUpdateClient"
|
:contacts="contacts_client"
|
||||||
|
:is-loading="clientStore.isLoading"
|
||||||
|
:client-avatar="clientAvatar"
|
||||||
|
:active-tab="activeTab"
|
||||||
|
:file-input="fileInput"
|
||||||
|
:contact-loading="contactStore.isLoading"
|
||||||
|
@update-the-client="updateClient"
|
||||||
|
@add-new-contact="createNewContact"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="clientStore.isLoading" class="text-center p-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Chargement...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="clientStore.hasError" class="alert alert-danger m-3">
|
|
||||||
{{ clientStore.getError }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
|
||||||
import ClientDetailPresentation from "@/components/Organism/CRM/ClientDetailPresentation.vue";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { onMounted } from "vue";
|
|
||||||
import { useClientStore } from "@/stores/clientStore";
|
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
import { useContactStore } from "@/stores/contactStore";
|
||||||
|
import ClientDetailPresentation from "@/components/Organism/CRM/ClientDetailPresentation.vue";
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const clientStore = useClientStore();
|
const clientStore = useClientStore();
|
||||||
|
const contactStore = useContactStore();
|
||||||
|
|
||||||
const client_id = route.params.id;
|
// Ensure client_id is a number
|
||||||
|
const client_id = Number(route.params.id);
|
||||||
|
const contacts_client = ref([]);
|
||||||
|
const activeTab = ref("overview");
|
||||||
|
const clientAvatar = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (client_id) {
|
if (client_id) {
|
||||||
await clientStore.fetchClient(Number(client_id));
|
await clientStore.fetchClient(client_id);
|
||||||
|
contacts_client.value = await contactStore.getClientListContact(client_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateClient = async (updateData) => {
|
const updateClient = async (data) => {
|
||||||
|
if (!client_id) {
|
||||||
|
console.error("Missing client id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If data is FormData (e.g. file upload), append the id instead of spreading
|
||||||
|
if (data instanceof FormData) {
|
||||||
|
data.set("id", String(client_id));
|
||||||
|
await clientStore.updateClient(data);
|
||||||
|
} else {
|
||||||
|
await clientStore.updateClient({ id: client_id, ...data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewContact = async (data) => {
|
||||||
try {
|
try {
|
||||||
await clientStore.updateClient(updateData);
|
await contactStore.createContact(data);
|
||||||
// Optionally show a success message
|
// Refresh contacts list after creation
|
||||||
console.log("Client mis à jour avec succès");
|
contacts_client.value = await contactStore.getClientListContact(client_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors de la mise à jour du client:", error);
|
console.error("Error creating contact:", error);
|
||||||
// Optionally show an error message to the user
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user