detail client

This commit is contained in:
Nyavokevin 2025-10-20 15:58:25 +03:00
parent 98420a29b5
commit e2cb4499bb
27 changed files with 3245 additions and 94 deletions

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -7,4 +7,6 @@ namespace App\Repositories;
interface ContactRepositoryInterface extends BaseRepositoryInterface
{
function paginate(int $perPage = 15, array $filters = []);
function getByClientId(int $clientId);
}

View File

@ -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);

View 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`

View File

@ -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>

View File

@ -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";

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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,
};
});

View 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>

View File

@ -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>