detail client
This commit is contained in:
parent
98420a29b5
commit
e2cb4499bb
@ -164,4 +164,27 @@ class ContactController extends Controller
|
||||
], 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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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-locations', ClientLocationController::class);
|
||||
|
||||
|
||||
// Contact management
|
||||
Route::apiResource('contacts', ContactController::class);
|
||||
Route::get('clients/{clientId}/contacts', [ContactController::class, 'getContactsByClient']);
|
||||
|
||||
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>
|
||||
<client-detail-template>
|
||||
<template #client-information>
|
||||
<client-information
|
||||
:client_id="client.id"
|
||||
:name="client.name"
|
||||
:categorie="client.categorie"
|
||||
:siret="client.siret"
|
||||
:vat_number="client.vat_number"
|
||||
:phone="client.phone"
|
||||
:email="client.email"
|
||||
:billing_address_line1="client.billing_address_line1"
|
||||
:billing_postal_code="client.billing_postal_code"
|
||||
:billing_city="client.billing_city"
|
||||
:billing_country_code="client.billing_country_code"
|
||||
:notes="client.notes"
|
||||
:is_active="client.is_active"
|
||||
:action="{
|
||||
route: `/clients/${client.id}/edit`,
|
||||
tooltip: 'Modifier les informations'
|
||||
}"
|
||||
@update:client="handleUpdateClient"
|
||||
<template #button-return>
|
||||
<div class="col-12">
|
||||
<router-link
|
||||
to="/crm/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>
|
||||
</template>
|
||||
<template #loading-state>
|
||||
<div v-if="isLoading" class="text-center p-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #client-detail-sidebar>
|
||||
<ClientDetailSidebar
|
||||
: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 #contact-list>
|
||||
<list-contact />
|
||||
<template #file-input>
|
||||
<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>
|
||||
</client-detail-template>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import { defineProps, defineEmits, ref } from "vue";
|
||||
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
|
||||
import ClientInformation from "@/components/molecules/client/ClientInformation.vue";
|
||||
import ListContact from "@/components/molecules/client/ListContact.vue";
|
||||
import ClientDetailSidebar from "./client/ClientDetailSidebar.vue";
|
||||
import ClientDetailContent from "./client/ClientDetailContent.vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
const props = defineProps({
|
||||
client: {
|
||||
type: Object,
|
||||
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) => {
|
||||
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>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<template #contact-table>
|
||||
<contact-table :data="contacts" />
|
||||
</template>
|
||||
</contact-template>
|
||||
</contact-template>s
|
||||
</template>
|
||||
<script setup>
|
||||
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>
|
||||
<div class="card">
|
||||
<div class="p-3 pb-0 card-header">
|
||||
<div class="card contacts-container">
|
||||
<div
|
||||
class="p-2 card-header d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h6 class="mb-0">Contacts</h6>
|
||||
</div>
|
||||
<div class="p-3 card-body border-radius-lg">
|
||||
<div
|
||||
v-for="contact of contacts"
|
||||
:key="contact.id"
|
||||
class="d-flex"
|
||||
:class="index !== 0 ? 'mt-4' : ''"
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="addContact"
|
||||
title="Add contact"
|
||||
>
|
||||
<div class="ms-3">
|
||||
<div class="numbers">
|
||||
<h6 class="mb-1 text-sm text-dark">
|
||||
{{ contact.first_name + " " + contact.first_name }}
|
||||
</h6>
|
||||
<span class="text-sm">{{ email }}</span>
|
||||
<span class="text-sm">{{ phone }}</span>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2 card-body">
|
||||
<div
|
||||
v-for="(contact, index) of contacts"
|
||||
: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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
defineProps({
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
type: Object,
|
||||
default: [],
|
||||
type: Array,
|
||||
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>
|
||||
|
||||
<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>
|
||||
<div class="container-fluid">
|
||||
<div class="py-4 container fluid">
|
||||
<div class="mt-3 row">
|
||||
<div class="col-12 col-md-6 col-xl-4 mt-md-0">
|
||||
<slot name="client-information" />
|
||||
</div>
|
||||
<div class="col-12 col-md-3 col-xl-r mt-md-0">
|
||||
<slot name="contact-list" />
|
||||
</div>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<slot name="button-return" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<slot name="client-detail-sidebar" />
|
||||
<slot name="file-input" />
|
||||
</div>
|
||||
<div class="col-lg-9 mt-lg-0 mt-4">
|
||||
<slot name="client-detail-content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
UpdateClientPayload,
|
||||
ClientListResponse,
|
||||
} from "@/services/client";
|
||||
import { Contact } from "@/services/contact";
|
||||
|
||||
export const useClientStore = defineStore("client", () => {
|
||||
// State
|
||||
@ -16,6 +17,7 @@ export const useClientStore = defineStore("client", () => {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const searchResults = ref<Client[]>([]);
|
||||
const contacts_client = ref<Contact[]>([]);
|
||||
|
||||
// Pagination state
|
||||
const pagination = ref({
|
||||
@ -66,6 +68,10 @@ export const useClientStore = defineStore("client", () => {
|
||||
searchResults.value = searchClient;
|
||||
};
|
||||
|
||||
const setContactClient = (contactClient: Contact[]) => {
|
||||
contacts_client.value = contactClient;
|
||||
};
|
||||
|
||||
const setPagination = (meta: any) => {
|
||||
if (meta) {
|
||||
pagination.value = {
|
||||
@ -159,6 +165,7 @@ export const useClientStore = defineStore("client", () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log(payload);
|
||||
const response = await ClientService.updateClient(payload);
|
||||
const updatedClient = response.data;
|
||||
|
||||
|
||||
@ -26,25 +26,25 @@ export const useContactStore = defineStore("contact", () => {
|
||||
|
||||
// Getters
|
||||
const allContacts = computed(() => contacts.value);
|
||||
|
||||
|
||||
const primaryContacts = computed(() =>
|
||||
contacts.value.filter((contact) => contact.is_primary)
|
||||
);
|
||||
|
||||
|
||||
const secondaryContacts = computed(() =>
|
||||
contacts.value.filter((contact) => !contact.is_primary)
|
||||
);
|
||||
|
||||
|
||||
const isLoading = computed(() => loading.value);
|
||||
const hasError = computed(() => error.value !== null);
|
||||
const getError = computed(() => error.value);
|
||||
|
||||
|
||||
const getContactById = computed(() => (id: number) =>
|
||||
contacts.value.find((contact) => contact.id === id)
|
||||
);
|
||||
|
||||
|
||||
const getPagination = computed(() => pagination.value);
|
||||
|
||||
|
||||
const getContactFullName = computed(() => (contact: Contact) =>
|
||||
ContactService.getFullName(contact)
|
||||
);
|
||||
@ -102,7 +102,9 @@ export const useContactStore = defineStore("contact", () => {
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -123,7 +125,9 @@ export const useContactStore = defineStore("contact", () => {
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -146,7 +150,9 @@ export const useContactStore = defineStore("contact", () => {
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} 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
|
||||
if (currentContact.value && currentContact.value.id === updatedContact.id) {
|
||||
if (
|
||||
currentContact.value &&
|
||||
currentContact.value.id === updatedContact.id
|
||||
) {
|
||||
setCurrentContact(updatedContact);
|
||||
}
|
||||
|
||||
return updatedContact;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -210,7 +221,9 @@ export const useContactStore = defineStore("contact", () => {
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -240,7 +253,9 @@ export const useContactStore = defineStore("contact", () => {
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -323,7 +338,10 @@ export const useContactStore = defineStore("contact", () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await ContactService.getContactsByClient(clientId, params);
|
||||
const response = await ContactService.getContactsByClient(
|
||||
clientId,
|
||||
params
|
||||
);
|
||||
setContacts(response.data);
|
||||
if (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
|
||||
*/
|
||||
@ -393,6 +433,7 @@ export const useContactStore = defineStore("contact", () => {
|
||||
setPrimaryContact,
|
||||
fetchContactsByClient,
|
||||
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>
|
||||
<client-detail-presentation
|
||||
<client-detail-presentation
|
||||
v-if="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>
|
||||
<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 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 () => {
|
||||
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 {
|
||||
await clientStore.updateClient(updateData);
|
||||
// Optionally show a success message
|
||||
console.log("Client mis à jour avec succès");
|
||||
await contactStore.createContact(data);
|
||||
// Refresh contacts list after creation
|
||||
contacts_client.value = await contactStore.getClientListContact(client_id);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la mise à jour du client:", error);
|
||||
// Optionally show an error message to the user
|
||||
console.error("Error creating contact:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user