client liste et formulaire

This commit is contained in:
Nyavokevin 2025-10-09 18:25:02 +03:00
parent 215f4c4071
commit 175446adbe
51 changed files with 4385 additions and 814 deletions

View File

@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClientCategoryRequest;
use App\Http\Resources\Client\ClientCategoryResource;
use App\Http\Resources\ClientResource;
use App\Models\ClientCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ClientCategoryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): AnonymousResourceCollection
{
$query = ClientCategory::query();
$categories = $query->get();
return ClientCategoryResource::collection($categories);
}
/**
* Store a newly created resource in storage.
*/
public function store(ClientCategoryRequest $request): ClientCategoryResource
{
$category = ClientCategory::create($request->validated());
return new ClientCategoryResource($category);
}
/**
* Display the specified resource.
*/
public function show(ClientCategory $clientCategory): ClientCategoryResource
{
return new ClientCategoryResource($clientCategory);
}
/**
* Display the specified resource by slug.
*/
public function showBySlug(string $slug): ClientCategoryResource
{
$category = ClientCategory::where('slug', $slug)->firstOrFail();
return new ClientCategoryResource($category);
}
/**
* Update the specified resource in storage.
*/
public function update(ClientCategoryRequest $request, ClientCategory $clientCategory): ClientCategoryResource
{
$clientCategory->update($request->validated());
return new ClientCategoryResource($clientCategory->fresh());
}
/**
* Remove the specified resource from storage.
*/
public function destroy(ClientCategory $clientCategory): JsonResponse
{
// Check if category has clients
if ($clientCategory->clients()->exists()) {
return response()->json([
'success' => false,
'message' => 'Cannot delete category that has clients assigned. Please reassign clients first.'
], 422);
}
$clientCategory->delete();
return response()->json([
'success' => true,
'message' => 'Client category deleted successfully.'
]);
}
/**
* Toggle active status of the category.
*/
public function toggleStatus(ClientCategory $clientCategory, Request $request): ClientCategoryResource
{
$request->validate([
'is_active' => 'required|boolean',
]);
$clientCategory->update([
'is_active' => $request->boolean('is_active'),
]);
return new ClientCategoryResource($clientCategory->fresh());
}
/**
* Get clients for a specific category.
*/
public function clients(ClientCategory $clientCategory, Request $request): AnonymousResourceCollection
{
$query = $clientCategory->clients();
// Active status filter
if ($request->has('is_active')) {
$query->where('is_active', $request->boolean('is_active'));
}
// Pagination
$perPage = $request->get('per_page', 15);
$clients = $query->paginate($perPage);
return ClientResource::collection($clients);
}
/**
* Reorder categories.
*/
public function reorder(Request $request): JsonResponse
{
$request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:client_categories,id',
]);
foreach ($request->order as $index => $categoryId) {
ClientCategory::where('id', $categoryId)->update(['sort_order' => $index]);
}
return response()->json([
'success' => true,
'message' => 'Categories reordered successfully.'
]);
}
/**
* Get all active categories for dropdowns.
*/
public function active(): AnonymousResourceCollection
{
$categories = ClientCategory::where('is_active', true)
->orderBy('sort_order', 'asc')
->orderBy('name', 'asc')
->get();
return ClientCategoryResource::collection($categories);
}
}

View File

@ -8,10 +8,12 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreClientRequest;
use App\Http\Requests\UpdateClientRequest;
use App\Http\Resources\Client\ClientResource;
use App\Http\Resources\Client\ClientCollection;
use App\Repositories\ClientRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
class ClientController extends Controller
{
@ -23,11 +25,28 @@ class ClientController extends Controller
/**
* Display a listing of clients.
*/
public function index(): AnonymousResourceCollection|JsonResponse
public function index(Request $request): ClientCollection|JsonResponse
{
try {
$clients = $this->clientRepository->all();
return ClientResource::collection($clients);
$perPage = $request->get('per_page', 15);
$filters = [
'search' => $request->get('search'),
'is_active' => $request->get('is_active'),
'group_id' => $request->get('group_id'),
'client_category_id' => $request->get('client_category_id'),
'sort_by' => $request->get('sort_by', 'created_at'),
'sort_direction' => $request->get('sort_direction', 'desc'),
];
// Remove null filters
$filters = array_filter($filters, function ($value) {
return $value !== null && $value !== '';
});
$clients = $this->clientRepository->paginate($perPage, $filters);
return new ClientCollection($clients);
} catch (\Exception $e) {
Log::error('Error fetching clients: ' . $e->getMessage(), [
'exception' => $e,

View File

@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ClientCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$categoryId = $this->route('client_category')?->id;
return [
'name' => [
'required',
'string',
'max:255',
Rule::unique('client_categories')->ignore($categoryId)
],
'slug' => [
'nullable',
'string',
'max:255',
'alpha_dash',
Rule::unique('client_categories')->ignore($categoryId)
],
'description' => 'nullable|string|max:1000',
'is_active' => 'boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
public function attributes(): array
{
return [
'name' => 'category name',
'slug' => 'URL slug',
];
}
protected function prepareForValidation(): void
{
// Generate slug from name if not provided and name exists
if (!$this->slug && $this->name) {
$this->merge([
'slug' => \Str::slug($this->name),
]);
}
// Ensure boolean values are properly cast
if ($this->has('is_active')) {
$this->merge([
'is_active' => filter_var($this->is_active, FILTER_VALIDATE_BOOLEAN),
]);
}
}
}

View File

@ -22,8 +22,7 @@ class StoreClientRequest extends FormRequest
public function rules(): array
{
return [
'type' => 'required|in:pompes_funebres,famille,entreprise,collectivite,autre',
'client_category_id' => 'nullable',
'name' => 'required|string|max:255',
'vat_number' => 'nullable|string|max:32',
'siret' => 'nullable|string|max:20',

View File

@ -11,7 +11,7 @@ class StoreContactRequest extends FormRequest
*/
public function authorize(): bool
{
return false;
return true;
}
/**
@ -21,8 +21,41 @@ class StoreContactRequest extends FormRequest
*/
public function rules(): array
{
return [
//
return [
'client_id' => 'required|exists:clients,id',
'first_name' => 'nullable|string|max:191',
'last_name' => 'nullable|string|max:191',
'email' => 'nullable|email|max:191',
'phone' => 'nullable|string|max:50',
'role' => 'nullable|string|max:191',
];
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné n\'existe pas.',
'first_name.string' => 'Le prénom doit être une chaîne de caractères.',
'first_name.max' => 'Le prénom ne peut pas dépasser 191 caractères.',
'last_name.string' => 'Le nom doit être une chaîne de caractères.',
'last_name.max' => 'Le nom ne peut pas dépasser 191 caractères.',
'email.email' => 'L\'adresse email doit être valide.',
'email.max' => 'L\'adresse email ne peut pas dépasser 191 caractères.',
'phone.max' => 'Le téléphone ne peut pas dépasser 50 caractères.',
'role.max' => 'Le rôle ne peut pas dépasser 191 caractères.',
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
if (empty($this->first_name) && empty($this->last_name) && empty($this->email) && empty($this->phone)) {
$validator->errors()->add(
'general',
'Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.'
);
}
});
}
}

View File

@ -11,7 +11,7 @@ class UpdateContactRequest extends FormRequest
*/
public function authorize(): bool
{
return false;
return true;
}
/**
@ -22,7 +22,41 @@ class UpdateContactRequest extends FormRequest
public function rules(): array
{
return [
//
'client_id' => 'required|exists:clients,id',
'first_name' => 'nullable|string|max:191',
'last_name' => 'nullable|string|max:191',
'email' => 'nullable|email|max:191',
'phone' => 'nullable|string|max:50',
'role' => 'nullable|string|max:191',
];
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné n\'existe pas.',
'first_name.string' => 'Le prénom doit être une chaîne de caractères.',
'first_name.max' => 'Le prénom ne peut pas dépasser 191 caractères.',
'last_name.string' => 'Le nom doit être une chaîne de caractères.',
'last_name.max' => 'Le nom ne peut pas dépasser 191 caractères.',
'email.email' => 'L\'adresse email doit être valide.',
'email.max' => 'L\'adresse email ne peut pas dépasser 191 caractères.',
'phone.max' => 'Le téléphone ne peut pas dépasser 50 caractères.',
'role.max' => 'Le rôle ne peut pas dépasser 191 caractères.',
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
if (empty($this->first_name) && empty($this->last_name) && empty($this->email) && empty($this->phone)) {
$validator->errors()->add(
'general',
'Au moins un champ (prénom, nom, email ou téléphone) doit être renseigné.'
);
}
});
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ClientCategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'is_active' => $this->is_active,
'sort_order' => $this->sort_order,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
// Relationships (loaded when needed)
// 'clients_count' => $this->whenCounted('clients'),
// 'clients' => ClientResource::collection($this->whenLoaded('clients')),
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Resources;
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
@ -26,7 +26,7 @@ class ClientCollection extends ResourceCollection
'stats' => [
'active' => $this->collection->where('is_active', true)->count(),
'inactive' => $this->collection->where('is_active', false)->count(),
'by_type' => $this->collection->groupBy('type')->map->count(),
'by_type' => $this->collection->groupBy('client_category_id')->map->count(),
],
],
'links' => [

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Resources;
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

View File

@ -14,6 +14,38 @@ class ClientLocationResource extends JsonResource
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
return [
'id' => $this->id,
'client_id' => $this->client_id,
'name' => $this->name,
'address' => [
'line1' => $this->address_line1,
'line2' => $this->address_line2,
'postal_code' => $this->postal_code,
'city' => $this->city,
'country_code' => $this->country_code,
'full_address' => $this->full_address,
],
'gps_coordinates' => $this->gps_coordinates,
'gps_lat' => $this->gps_lat,
'gps_lng' => $this->gps_lng,
'is_default' => $this->is_default,
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'client' => new ClientResource($this->whenLoaded('client')),
//'interventions_as_origin' => InterventionResource::collection($this->whenLoaded('interventionsAsOrigin')),
//'transports_as_origin' => TransportResource::collection($this->whenLoaded('transportsAsOrigin')),
//'transports_as_destination' => TransportResource::collection($this->whenLoaded('transportsAsDestination')),
];
}
public function with(Request $request): array
{
return [
'status' => 'success',
'message' => 'Lieu client récupéré avec succès',
];
}
}

View File

@ -18,7 +18,7 @@ class ClientResource extends JsonResource
return [
'id' => $this->id,
//'company_id' => $this->company_id,
'type' => $this->type,
'commercial' => $this->commercial(),
'type_label' => $this->getTypeLabel(),
'name' => $this->name,
'vat_number' => $this->vat_number,

View File

@ -14,6 +14,28 @@ class ContactResource extends JsonResource
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
return [
'id' => $this->id,
'client_id' => $this->client_id,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'email' => $this->email,
'phone' => $this->phone,
'role' => $this->role,
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'client' => new ClientResource($this->whenLoaded('client')),
];
}
public function with(Request $request): array
{
return [
'status' => 'success',
'message' => 'Contact récupéré avec succès',
];
}
}

View File

@ -3,11 +3,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Client extends Model
{
protected $fillable = [
'type',
'name',
'vat_number',
'siret',
@ -21,26 +21,36 @@ class Client extends Model
'group_id',
'notes',
'is_active',
'default_tva_rate_id',
// 'default_tva_rate_id',
'client_category_id',
'user_id',
];
protected $casts = [
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function commercial(): ?string
{
return $this->user ? $this->user->name : 'Système';
}
public function category(): BelongsTo
{
return $this->belongsTo(ClientCategory::class, 'client_category_id');
}
/**
* Get the human-readable label for the client type.
*/
public function getTypeLabel(): string
{
return match($this->type) {
'pompes_funebres' => 'Pompes funèbres',
'famille' => 'Famille',
'entreprise' => 'Entreprise',
'collectivite' => 'Collectivité',
'autre' => 'Autre',
default => $this->type,
};
return $this->category ? $this->category->name : 'Non catégorisé';
}
/**

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClientCategory extends Model
{
protected $fillable = [
'name',
'slug',
'description',
'is_active',
'sort_order',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* Get the clients for the category.
*/
public function clients(): HasMany
{
return $this->hasMany(Client::class);
}
}

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Repositories;
use App\Models\Client;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class ClientRepository extends BaseRepository implements ClientRepositoryInterface
{
@ -12,4 +14,42 @@ class ClientRepository extends BaseRepository implements ClientRepositoryInterfa
{
parent::__construct($model);
}
/**
* Get paginated clients
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery();
// Apply filters
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('name', 'like', '%' . $filters['search'] . '%')
->orWhere('email', 'like', '%' . $filters['search'] . '%')
->orWhere('vat_number', 'like', '%' . $filters['search'] . '%')
->orWhere('siret', 'like', '%' . $filters['search'] . '%');
});
}
if (isset($filters['is_active'])) {
$query->where('is_active', $filters['is_active']);
}
if (!empty($filters['group_id'])) {
$query->where('group_id', $filters['group_id']);
}
if (!empty($filters['client_category_id'])) {
$query->where('client_category_id', $filters['client_category_id']);
}
// Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($perPage);
}
}

View File

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ClientRepositoryInterface extends BaseRepositoryInterface
{
// Add Client-specific methods here later if needed
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
}

View File

@ -9,7 +9,7 @@ return [
// IMPORTANT: Do NOT use '*' when sending credentials. List explicit origins.
// Set FRONTEND_URL in .env to override the default if needed.
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8080')],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8080', 'http://localhost:8081')],
// Alternatively, use patterns (kept empty for clarity)
'allowed_origins_patterns' => [],

View File

@ -13,6 +13,12 @@ return new class extends Migration
{
Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained()->onDelete('cascade');
$table->string('first_name', 191)->nullable();
$table->string('last_name', 191)->nullable();
$table->string('email', 191)->nullable();
$table->string('phone', 50)->nullable();
$table->string('role', 191)->nullable();
$table->timestamps();
});
}

View File

@ -0,0 +1,126 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create client_categories table
Schema::create('client_categories', function (Blueprint $table) {
$table->id();
$table->string('name', 255);
$table->string('slug', 255)->unique();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['slug', 'is_active']);
});
// Insert default categories
DB::table('client_categories')->insert([
[
'name' => 'Pompes funèbres',
'slug' => 'pompes_funebres',
'description' => 'Professionnels des pompes funèbres',
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Famille',
'slug' => 'famille',
'description' => 'Particuliers et familles',
'sort_order' => 2,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Entreprise',
'slug' => 'entreprise',
'description' => 'Entreprises et sociétés',
'sort_order' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Collectivité',
'slug' => 'collectivite',
'description' => 'Collectivités territoriales',
'sort_order' => 4,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Autre',
'slug' => 'autre',
'description' => 'Autres types de clients',
'sort_order' => 5,
'created_at' => now(),
'updated_at' => now(),
],
]);
// Add client_category_id to clients table
Schema::table('clients', function (Blueprint $table) {
$table->foreignId('client_category_id')->nullable()->after('type')
->constrained('client_categories')->onDelete('set null');
});
// Migrate existing type data to new client_category_id
$categories = DB::table('client_categories')->pluck('id', 'slug');
foreach ($categories as $slug => $id) {
DB::table('clients')
->where('type', $slug)
->update(['client_category_id' => $id]);
}
// Remove the old type column
Schema::table('clients', function (Blueprint $table) {
$table->dropColumn('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Add back the type column
Schema::table('clients', function (Blueprint $table) {
$table->enum('type', [
'pompes_funebres',
'famille',
'entreprise',
'collectivite',
'autre'
])->default('pompes_funebres')->after('id');
});
// Migrate data back to type column
$categories = DB::table('client_categories')->pluck('slug', 'id');
foreach ($categories as $id => $slug) {
DB::table('clients')
->where('client_category_id', $id)
->update(['type' => $slug]);
}
// Remove client_category_id
Schema::table('clients', function (Blueprint $table) {
$table->dropForeign(['client_category_id']);
$table->dropColumn('client_category_id');
});
// Drop client_categories table
Schema::dropIfExists('client_categories');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('id')
->constrained('users')->onDelete('set null');
$table->index(['user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\Api\ClientController;
use App\Http\Controllers\Api\ClientGroupController;
use App\Http\Controllers\Api\ClientLocationController;
use App\Http\Controllers\Api\ContactController;
use App\Http\Controllers\Api\ClientCategoryController;
/*
|--------------------------------------------------------------------------
@ -36,7 +37,10 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('clients', ClientController::class);
Route::apiResource('client-groups', ClientGroupController::class);
Route::apiResource('client-locations', ClientLocationController::class);
// Contact management
Route::apiResource('contacts', ContactController::class);
Route::apiResource('client-categories', ClientCategoryController::class);
});

View File

@ -0,0 +1,271 @@
# Client Creation Flow - Complete Implementation
## Overview
Complete implementation of the client creation feature with:
- ✅ Form validation
- ✅ Error handling (Laravel validation errors)
- ✅ Loading states
- ✅ Success messages
- ✅ Auto-redirect after success
- ✅ Store integration
## Data Flow
```
AddClient.vue (Page)
↓ (passes props & handles events)
AddClientPresentation.vue (Organism)
↓ (passes props & emits)
NewClientForm.vue (Form Component)
↓ (user fills form & clicks submit)
↑ (emits createClient event)
AddClient.vue (receives event)
↓ (calls store)
clientStore.createClient()
↓ (API call)
Laravel ClientController
↓ (validates with StoreClientRequest)
↓ (creates client or returns 422 errors)
↑ (returns success or validation errors)
AddClient.vue (handles response)
↓ (passes validation errors back to form)
NewClientForm.vue (displays errors under inputs)
```
## Components Updated
### 1. AddClient.vue (Page Component)
**Location:** `src/views/pages/CRM/AddClient.vue`
**Responsibilities:**
- Fetch client categories on mount
- Handle form submission via `handleCreateClient`
- Call store to create client
- Handle validation errors from API (422 status)
- Show success message
- Redirect to clients list after success
**Key Code:**
```vue
<script setup>
const handleCreateClient = async (form) => {
try {
validationErrors.value = {};
showSuccess.value = false;
const client = await clientStore.createClient(form);
showSuccess.value = true;
setTimeout(() => {
router.push({ name: 'Clients' });
}, 2000);
} catch (error) {
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
}
}
};
</script>
```
### 2. AddClientPresentation.vue (Organism)
**Location:** `src/components/Organism/CRM/AddClientPresentation.vue`
**Responsibilities:**
- Pass props to NewClientForm
- Relay events from form to parent
**Props Added:**
- `loading`: Boolean - loading state from store
- `validationErrors`: Object - validation errors from API
- `success`: Boolean - success state
### 3. NewClientForm.vue (Form Component)
**Location:** `src/components/molecules/form/NewClientForm.vue`
**Responsibilities:**
- Display form fields with proper validation styling
- Watch for validation errors from parent
- Display errors below each input
- Show loading spinner on button
- Show success alert
- Emit createClient event with form data
- Reset form on success
**Key Features:**
```vue
// Watch for validation errors
watch(() => props.validationErrors, (newErrors) => {
fieldErrors.value = { ...newErrors };
}, { deep: true });
// Watch for success
watch(() => props.success, (newSuccess) => {
if (newSuccess) {
resetForm();
}
});
// Submit form
const submitForm = async () => {
fieldErrors.value = {};
errors.value = [];
emit("createClient", form);
};
```
**Validation Error Display:**
```vue
<soft-input
v-model="form.name"
:class="{ 'is-invalid': fieldErrors.name }"
type="text"
/>
<div v-if="fieldErrors.name" class="invalid-feedback">
{{ fieldErrors.name }}
</div>
```
## Laravel Backend
### StoreClientRequest Validation Rules
```php
public function rules(): array
{
return [
'client_category_id' => 'nullable',
'name' => 'required|string|max:255',
'vat_number' => 'nullable|string|max:32',
'siret' => 'nullable|string|max:20',
'email' => 'nullable|email|max:191',
'phone' => 'nullable|string|max:50',
'billing_address_line1' => 'nullable|string|max:255',
'billing_address_line2' => 'nullable|string|max:255',
'billing_postal_code' => 'nullable|string|max:20',
'billing_city' => 'nullable|string|max:191',
'billing_country_code' => 'nullable|string|size:2',
'group_id' => 'nullable|exists:client_groups,id',
'notes' => 'nullable|string',
'is_active' => 'boolean',
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
];
}
```
## Test Data (Postman)
```json
{
"client_category_id": 1,
"name": "SARL TechnoPlus",
"vat_number": "FR98765432109",
"siret": "98765432100019",
"email": "compta@technoplus.fr",
"phone": "+33198765432",
"billing_address_line1": "789 Boulevard Haussmann",
"billing_postal_code": "75009",
"billing_city": "Paris",
"billing_country_code": "FR",
"notes": "Nouveau client entreprise",
"is_active": true
}
```
## Error Handling
### Validation Errors (422)
When Laravel returns validation errors:
```json
{
"message": "The given data was invalid.",
"errors": {
"name": ["Le nom du client est obligatoire."],
"email": ["L'adresse email doit être valide."]
}
}
```
These are automatically displayed below each input field.
### Server Errors (500)
When server error occurs:
- Alert is shown with error message
- User can retry
### Network Errors
When network is unavailable:
- Alert is shown with generic error message
## User Experience Flow
1. **User fills the form**
- All fields are validated client-side (maxlength, email format)
2. **User clicks "Créer le client"**
- Button shows loading spinner
- Button is disabled
- Previous errors are cleared
3. **If validation fails (422)**
- Errors appear below each invalid field in red
- Loading stops
- Button is re-enabled
- User can fix errors and resubmit
4. **If creation succeeds**
- Success message appears in green
- Form is reset
- After 2 seconds, user is redirected to clients list
## Required Fields
Only **name** is required. All other fields are optional.
- ✅ Name: Required (max 255 chars)
- ⭕ Email: Optional but must be valid email format
- ⭕ VAT Number: Optional (max 32 chars)
- ⭕ SIRET: Optional (max 20 chars)
- ⭕ Phone: Optional (max 50 chars)
- ⭕ Address fields: Optional
- ⭕ Country code: Optional (must be 2 chars if provided)
- ⭕ Notes: Optional
- ✅ Active status: Defaults to true
## CSS Classes for Validation
```css
.is-invalid {
border-color: #f5365c !important;
}
.invalid-feedback {
display: block;
color: #f5365c;
font-size: 0.875rem;
margin-top: 0.25rem;
}
```
## Testing Checklist
- [ ] Submit empty form → "name" required error shows
- [ ] Submit with invalid email → email validation error shows
- [ ] Submit with VAT > 32 chars → VAT length error shows
- [ ] Submit with SIRET > 20 chars → SIRET length error shows
- [ ] Submit valid data → Success message & redirect
- [ ] Check loading spinner appears during submission
- [ ] Check button is disabled during submission
- [ ] Check form resets after success
- [ ] Check redirect happens after 2 seconds
## Future Enhancements
- [ ] Add client category dropdown (currently just ID)
- [ ] Add client group dropdown
- [ ] Add TVA rate dropdown
- [ ] Add real-time validation as user types
- [ ] Add confirmation modal before submit
- [ ] Add ability to create contact at same time
- [ ] Add file upload for documents

View File

@ -0,0 +1,205 @@
# ContactTable Component Fix
## Problem
The `<contact-table />` component was not showing in the Contacts.vue page.
## Root Cause
The **ContactTable.vue** component had an incomplete `<script>` section:
- Missing `export default` statement
- Not using `<script setup>` properly
- Components (SoftCheckbox, SoftButton, SoftAvatar) were not imported
- Images (img1-img6) were imported but not exposed to the template
- No proper component setup
## Solution
Converted both files to use Vue 3 `<script setup>` without TypeScript.
### Changes Made
#### 1. ContactTable.vue (`src/components/molecules/Tables/ContactTable.vue`)
**Before:**
```vue
<script>
import { DataTable } from "simple-datatables";
import img1 from "../../../assets/img/team-2.jpg";
// ... more imports but no export or setup
</script>
```
**After:**
```vue
<script setup>
import { onMounted } from 'vue'
import { DataTable } from 'simple-datatables'
import SoftCheckbox from '@/components/SoftCheckbox.vue'
import SoftButton from '@/components/SoftButton.vue'
import SoftAvatar from '@/components/SoftAvatar.vue'
import img1 from '@/assets/img/team-2.jpg'
import img2 from '@/assets/img/team-1.jpg'
import img3 from '@/assets/img/team-3.jpg'
import img4 from '@/assets/img/team-4.jpg'
import img5 from '@/assets/img/team-5.jpg'
import img6 from '@/assets/img/ivana-squares.jpg'
onMounted(() => {
const dataTableEl = document.getElementById('order-list')
if (dataTableEl) {
new DataTable(dataTableEl, {
searchable: false,
fixedHeight: true
})
}
})
</script>
```
**Key fixes:**
- ✅ Added `<script setup>` for proper Vue 3 Composition API
- ✅ Imported required components (SoftCheckbox, SoftButton, SoftAvatar)
- ✅ Used `@/` alias for cleaner imports
- ✅ Added `onMounted` to initialize DataTable after component mounts
- ✅ All imports are now properly exposed to the template
#### 2. Contacts.vue (`src/views/pages/CRM/Contacts.vue`)
**Before:**
```vue
<script setup lang="ts">
import SoftButton from "@/components/SoftButton.vue";
import ContactTable from "@/components/molecules/Tables/ContactTable.vue";
</script>
```
**After:**
```vue
<script setup>
import SoftButton from '@/components/SoftButton.vue'
import ContactTable from '@/components/molecules/Tables/ContactTable.vue'
</script>
```
**Key fixes:**
- ✅ Removed TypeScript (`lang="ts"`)
- ✅ Consistent quote style
- ✅ Properly imports ContactTable component
## How `<script setup>` Works
With `<script setup>`:
- No need for `export default`
- All top-level imports are automatically available in the template
- All top-level variables are automatically reactive
- Cleaner, more concise syntax
- Better TypeScript inference (when needed)
## Component Structure
```
Contacts.vue (parent)
└── ContactTable.vue (child)
├── SoftCheckbox.vue
├── SoftButton.vue
└── SoftAvatar.vue
```
## Features Added
1. **DataTable Integration**: The table is now initialized as a DataTable on mount
2. **Component Registration**: All child components properly imported and registered
3. **Image Assets**: All team images properly imported and accessible
4. **Clean Setup**: No TypeScript complexity, pure Vue 3 Composition API
## Testing
To verify the fix works:
1. Navigate to the Contacts page
2. The table should now render with all data
3. DataTable features (sorting, etc.) should work
4. All avatars and images should display
## Common Issues with `<script setup>`
### ❌ Wrong - Old Options API style:
```vue
<script>
export default {
components: { MyComponent },
data() { return {} }
}
</script>
```
### ✅ Correct - Script setup:
```vue
<script setup>
import MyComponent from './MyComponent.vue'
// MyComponent is automatically registered
// No need for components: {} registration
</script>
```
### ❌ Wrong - Missing imports:
```vue
<template>
<soft-button /> <!-- Won't work, not imported -->
</template>
<script setup>
// Missing import!
</script>
```
### ✅ Correct - With imports:
```vue
<template>
<soft-button /> <!-- Works! -->
</template>
<script setup>
import SoftButton from '@/components/SoftButton.vue'
</script>
```
## Benefits of This Approach
1. **Simpler**: No TypeScript complexity
2. **Cleaner**: Less boilerplate code
3. **Standard**: Follows Vue 3 best practices
4. **Maintainable**: Easy to understand and modify
5. **Performant**: `<script setup>` has better runtime performance
## Next Steps
If you want to add more features:
### Add Search to the table:
```vue
<script setup>
import { ref } from 'vue'
const searchQuery = ref('')
onMounted(() => {
new DataTable(dataTableEl, {
searchable: true, // Enable search
fixedHeight: true
})
})
</script>
```
### Make the data dynamic:
```vue
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const contacts = ref([])
onMounted(async () => {
const response = await axios.get('/api/contacts')
contacts.value = response.data
})
</script>
```
Then loop through contacts in the template with `v-for`.

View File

@ -0,0 +1,286 @@
# Payload Cleaning - Form Data Transformation
## Problem
The form was sending empty strings (`""`) instead of `null` for empty fields, and checkbox was sending `0` instead of `true/false`.
## Solution
Added data cleaning logic before submitting the form.
## Changes Made
### 1. Clean Empty Strings to Null
**Before:**
```json
{
"name": "",
"email": "",
"phone": "",
"vat_number": ""
}
```
**After:**
```json
{
"name": null,
"email": null,
"phone": null,
"vat_number": null
}
```
### 2. Ensure Boolean for is_active
**Before:**
```json
{
"is_active": 0 // or "" or undefined
}
```
**After:**
```json
{
"is_active": true // or false
}
```
### 3. Remove Empty Type Field
The `type` field is not used by the backend, so we remove it if empty.
## Implementation
```javascript
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Clean up form data: convert empty strings to null
const cleanedForm = {};
for (const [key, value] of Object.entries(form)) {
if (value === '' || value === null || value === undefined) {
cleanedForm[key] = null;
} else {
cleanedForm[key] = value;
}
}
// Ensure is_active is boolean
cleanedForm.is_active = Boolean(form.is_active);
// Remove type field if it's empty (not needed for backend)
if (!cleanedForm.type) {
delete cleanedForm.type;
}
// Emit the cleaned form data to parent
emit("createClient", cleanedForm);
};
```
## Example Payloads
### Empty Form (Only Required Fields)
**Before cleaning:**
```json
{
"client_category_id": null,
"type": "",
"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": 0,
"default_tva_rate_id": null
}
```
**After cleaning:**
```json
{
"client_category_id": null,
"name": null,
"vat_number": null,
"siret": null,
"email": null,
"phone": null,
"billing_address_line1": null,
"billing_address_line2": null,
"billing_postal_code": null,
"billing_city": null,
"billing_country_code": null,
"group_id": null,
"notes": null,
"is_active": true,
"default_tva_rate_id": null
}
```
### Filled Form
**Before cleaning:**
```json
{
"client_category_id": 4,
"type": "",
"name": "SARL TechnoPlus",
"vat_number": "FR98765432109",
"siret": "98765432100019",
"email": "compta@technoplus.fr",
"phone": "+33198765432",
"billing_address_line1": "789 Boulevard Haussmann",
"billing_address_line2": "",
"billing_postal_code": "75009",
"billing_city": "Paris",
"billing_country_code": "FR",
"group_id": null,
"notes": "Nouveau client entreprise",
"is_active": 1,
"default_tva_rate_id": null
}
```
**After cleaning:**
```json
{
"client_category_id": 4,
"name": "SARL TechnoPlus",
"vat_number": "FR98765432109",
"siret": "98765432100019",
"email": "compta@technoplus.fr",
"phone": "+33198765432",
"billing_address_line1": "789 Boulevard Haussmann",
"billing_address_line2": null,
"billing_postal_code": "75009",
"billing_city": "Paris",
"billing_country_code": "FR",
"group_id": null,
"notes": "Nouveau client entreprise",
"is_active": true,
"default_tva_rate_id": null
}
```
## Benefits
### 1. Cleaner Database
- `null` values instead of empty strings
- Easier to query for "no value" vs "empty string"
### 2. Laravel Validation Works Better
Laravel handles `null` better than `""` for:
- Optional fields
- Email validation (null is ok, "" might fail)
- Exists validation (null is ok, "" will try to find empty key)
### 3. Boolean Logic Works Correctly
```php
// In Laravel
if ($request->is_active) {
// This now works correctly
}
```
### 4. Consistent Data Types
- Strings stay strings
- Nulls stay nulls
- Booleans stay booleans
- Numbers stay numbers
## Checkbox Behavior
### Initial State
```vue
<input
type="checkbox"
v-model="form.is_active"
:checked="form.is_active"
/>
```
```javascript
const form = reactive({
is_active: true, // ✅ Starts checked
});
```
### When User Unchecks
- `form.is_active` becomes `false`
- Payload sends: `"is_active": false`
### When User Checks
- `form.is_active` becomes `true`
- Payload sends: `"is_active": true`
## Testing
### Test 1: Submit Empty Form
Expected validation error:
```json
{
"errors": {
"name": ["Le nom du client est obligatoire."]
}
}
```
### Test 2: Submit with Only Name
Payload sent:
```json
{
"client_category_id": null,
"name": "Test Client",
"vat_number": null,
"siret": null,
"email": null,
"phone": null,
"billing_address_line1": null,
"billing_address_line2": null,
"billing_postal_code": null,
"billing_city": null,
"billing_country_code": null,
"group_id": null,
"notes": null,
"is_active": true,
"default_tva_rate_id": null
}
```
Backend should accept this and create client with only name and is_active set.
### Test 3: Submit with Inactive Client
Uncheck "Client actif" checkbox, then submit.
Payload sent:
```json
{
"name": "Inactive Client",
"is_active": false,
// ... other fields
}
```
## Why Not Clean on Backend?
We clean on frontend because:
1. **Better UX**: Smaller payload size
2. **Clearer Intent**: `null` explicitly means "no value"
3. **Type Safety**: Ensures correct data types before sending
4. **Validation**: Easier to validate before API call
5. **Debugging**: Easier to see what data is being sent
## Laravel Backend Handles Both
The Laravel backend will accept:
- `"field": null`
- `"field": ""` ✅ (but converts to null for nullable fields)
- `"field": "value"`
But we send `null` for consistency and clarity.

View File

@ -0,0 +1,44 @@
<template>
<new-client-template>
<template #multi-step> </template>
<template #client-form>
<new-client-form
:categories="categories"
:loading="loading"
:validation-errors="validationErrors"
:success="success"
@create-client="handleCreateClient"
/>
</template>
</new-client-template>
</template>
<script setup>
import NewClientTemplate from "@/components/templates/CRM/NewClientTemplate.vue";
import NewClientForm from "@/components/molecules/form/NewClientForm.vue";
import { defineProps, defineEmits } from "vue";
defineProps({
categories: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["createClient"]);
const handleCreateClient = (data) => {
emit("createClient", data);
};
</script>

View File

@ -0,0 +1,44 @@
<template>
<client-template>
<template #client-new-action>
<add-button text="Ajouter" @click="goToClient" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #client-other-action>
<table-action />
</template>
<template #client-table>
<client-table :data="clientData" :loading="loadingData" />
</template>
</client-template>
</template>
<script setup>
import ClientTemplate from "@/components/templates/CRM/ClientTemplate.vue";
import ClientTable from "@/components/molecules/Tables/CRM/ClientTable.vue";
import addButton from "@/components/molecules/new-button/addButton.vue";
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
import { defineProps } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
defineProps({
clientData: {
type: Array,
default: [],
},
loadingData: {
type: Boolean,
default: false,
},
});
const goToClient = () => {
router.push({
name: "Creation client",
});
};
</script>

View File

@ -0,0 +1,23 @@
<template>
<contact-template>
<template #contact-new-action>
<add-button text="Ajouter" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #contact-other-action>
<table-action />
</template>
<template #contact-table>
<contact-table />
</template>
</contact-template>
</template>
<script setup>
import ContactTemplate from "@/components/templates/CRM/ContactTemplate.vue";
import ContactTable from "@/components/molecules/Tables/ContactTable.vue";
import addButton from "@/components/molecules/new-button/addButton.vue";
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
</script>

View File

@ -0,0 +1,428 @@
<template>
<div class="table-container">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Commercial</th>
<th>Client</th>
<th>Address</th>
<th>Categories</th>
<th>Contact</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Commercial Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-checkbox"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
<!-- Client Name Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Address Column Skeleton -->
<td>
<div class="skeleton-text long"></div>
</td>
<!-- Categories Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Contact Column Skeleton -->
<td>
<div class="contact-info">
<div class="skeleton-text long mb-1"></div>
<div class="skeleton-text medium"></div>
</div>
</td>
<!-- Status Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="contact-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Commercial</th>
<th>Client</th>
<th>Address</th>
<th>Categories</th>
<th>Contact</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="client in data" :key="client.id">
<!-- Commercial Column -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ client.commercial }}
</p>
</div>
</td>
<!-- Client Name Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>{{ client.name }}</span>
</div>
</td>
<!-- Address Column (Shortened) -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{
getShortAddress(client.billing_address)
}}</span>
</td>
<!-- Categories Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getCategoryColor(client.type_label)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getCategoryIcon(client.type_label)"
aria-hidden="true"
></i>
</soft-button>
<span>{{ client.type_label }}</span>
</div>
</td>
<!-- Contact Column -->
<td class="text-xs font-weight-bold">
<div class="contact-info">
<div class="text-xs text-secondary">{{ client.email }}</div>
<div class="text-xs">{{ client.phone }}</div>
</div>
</td>
<!-- Status Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="client.is_active ? 'success' : 'danger'"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="client.is_active ? 'fas fa-check' : 'fas fa-times'"
aria-hidden="true"
></i>
</soft-button>
<span>{{ client.is_active ? "Active" : "Inactive" }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-users fa-3x text-muted"></i>
</div>
<h5 class="empty-title">No clients found</h5>
<p class="empty-text text-muted">
There are no clients to display at the moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { DataTable } from "simple-datatables";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps } from "vue";
// Sample avatar images
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
// Reactive data
const contacts = ref([]);
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
// Methods
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const getShortAddress = (address) => {
if (!address) return "N/A";
// Return just city and postal code for brevity
return `${address.postal_code} ${address.city}`;
};
const getCategoryColor = (type) => {
const colors = {
Entreprise: "info",
Particulier: "success",
Association: "warning",
};
return colors[type] || "secondary";
};
const getCategoryIcon = (type) => {
const icons = {
Entreprise: "fas fa-building",
Particulier: "fas fa-user",
Association: "fas fa-users",
};
return icons[type] || "fas fa-circle";
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
const dataTableEl = document.getElementById("contact-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 10,
perPageSelect: [5, 10, 15, 20],
});
}
};
// Watch for data changes to reinitialize datatable
watch(
() => props.data,
() => {
if (!props.loading) {
// Small delay to ensure DOM is updated
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
// Initialize data
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-checkbox {
width: 18px;
height: 18px;
border-radius: 3px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 40px;
}
.skeleton-text.medium {
width: 80px;
}
.skeleton-text.long {
width: 120px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.contact-info {
line-height: 1.2;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
</style>

View File

@ -0,0 +1,526 @@
<template>
<div class="table-responsive">
<table id="order-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Id</th>
<th>Date de creation</th>
<th>Nom</th>
<th>Customer</th>
<th>Product</th>
<th>Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10421</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 10:20 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="img1"
class="me-2"
size="xs"
alt="user image"
circular
/>
<span>Orlando Imieto</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Nike Sport V2</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$140,20</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10422</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 10:53 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="img2"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>Alice Murinho</span>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">Valvet T-shirt</span>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">$42,00</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10423</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 11:13 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="dark"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-undo" aria-hidden="true"></i>
</soft-button>
<span>Refunded</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-dark">
<span>M</span>
</div>
<span>Michael Mirra</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
Leather Wallet
<span class="text-secondary ms-2">+1 more</span>
</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$25,50</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10424</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 12:20 PM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="d-flex align-items-center">
<soft-avatar
:img="img3"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>Andrew Nichel</span>
</div>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Bracelet Onu-Lino</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$19,40</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10425</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 1:40 PM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="danger"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-times" aria-hidden="true"></i>
</soft-button>
<span>Canceled</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="d-flex align-items-center">
<soft-avatar
:img="img4"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>Sebastian Koga</span>
</div>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
Phone Case Pink
<span class="text-secondary ms-2">x 2</span>
</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$44,90</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10426</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 2:19 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-success">
<span>L</span>
</div>
<span>Laur Gilbert</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Backpack Niver</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$112,50</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10427</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">1 Nov, 3:42 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-dark">
<span>I</span>
</div>
<span>Iryna Innda</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Adidas Vio</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$200,00</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10428</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">2 Nov, 9:32 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-dark">
<span>A</span>
</div>
<span>Arrias Liunda</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Airpods 2 Gen</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$350,00</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10429</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">2 Nov, 10:14 AM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="d-flex align-items-center">
<soft-avatar
:img="img5"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>Rugna Ilpio</span>
</div>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Bracelet Warret</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$15,00</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10430</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">2 Nov, 12:56 PM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="dark"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-undo" aria-hidden="true"></i>
</soft-button>
<span>Refunded</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="d-flex align-items-center">
<soft-avatar
:img="img6"
size="xs"
class="me-2"
alt="user image"
circular
/>
<span>Anna Landa</span>
</div>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
Watter Bottle India
<span class="text-secondary ms-2">x 3</span>
</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$25,00</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10431</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">2 Nov, 3:12 PM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-dark">
<span>K</span>
</div>
<span>Karl Innas</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Kitchen Gadgets</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$164,90</span>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">#10432</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">2 Nov, 5:12 PM</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="success"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-check" aria-hidden="true"></i>
</soft-button>
<span>Paid</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<div class="avatar avatar-xs me-2 bg-gradient-info">
<span>O</span>
</div>
<span>Oana Kilas</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">Office Papers</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">$23,90</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { onMounted } from "vue";
import { DataTable } from "simple-datatables";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
onMounted(() => {
const dataTableEl = document.getElementById("order-list");
if (dataTableEl) {
new DataTable(dataTableEl, {
searchable: false,
fixedHeight: true,
});
}
});
</script>

View File

@ -0,0 +1,37 @@
<template>
<soft-button
id="navbarDropdownMenuLink2"
color="dark"
variant="outline"
class="dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>{{ title }}</soft-button
>
<ul
class="dropdown-menu dropdown-menu-lg-start px-2 py-3"
aria-labelledby="navbarDropdownMenuLink2"
style
>
<li v-for="obj in by" :key="obj.id">
<a class="dropdown-item border-radius-md" href="javascript:;">{{
obj
}}</a>
</li>
</ul>
</template>
<script setup>
import SoftButton from "@/components/SoftButton.vue";
import { defineProps } from "vue";
defineProps({
title: {
type: String,
default: "Filter par",
},
by: {
type: Array,
default: [],
},
});
</script>

View File

@ -0,0 +1,17 @@
<template>
<soft-button
class="btn-icon ms-2 export"
size
color="dark"
variant="outline"
data-type="csv"
>
<span class="btn-inner--icon">
<i class="ni ni-archive-2"></i>
</span>
<span class="btn-inner--text">Export CSV</span>
</soft-button>
</template>
<script setup>
import SoftButton from "@/components/SoftButton.vue";
</script>

View File

@ -0,0 +1,441 @@
<template>
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<h5 class="font-weight-bolder mb-0">Nouveau Client</h5>
<p class="mb-0 text-sm">Informations du client</p>
<!-- Message de succès -->
<div
v-if="props.success"
class="alert alert-success alert-dismissible fade show mt-3"
role="alert"
>
<span class="alert-icon"><i class="ni ni-like-2"></i></span>
<span class="alert-text"
><strong>Succès !</strong> Client créé avec succès ! Redirection en
cours...</span
>
</div>
<div class="multisteps-form__content">
<!-- Catégorie du client -->
<div class="row mt-3">
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Catégorie de client</label>
<select
:value="form.client_category_id"
@input="form.client_category_id = $event.target.value"
class="form-control multisteps-form__input"
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
</div>
<!-- Nom du client -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label"
>Nom du client <span class="text-danger">*</span></label
>
<soft-input
:value="form.name"
@input="form.name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.name }"
type="text"
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
/>
<div v-if="fieldErrors.name" class="invalid-feedback">
{{ fieldErrors.name }}
</div>
</div>
</div>
<!-- TVA & SIRET -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Numéro de TVA</label>
<soft-input
:value="form.vat_number"
@input="form.vat_number = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.vat_number }"
type="text"
placeholder="ex. FR12345678901"
maxlength="32"
/>
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
{{ fieldErrors.vat_number }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">SIRET</label>
<soft-input
:value="form.siret"
@input="form.siret = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.siret }"
type="text"
placeholder="ex. 12345678901234"
maxlength="20"
/>
<div v-if="fieldErrors.siret" class="invalid-feedback">
{{ fieldErrors.siret }}
</div>
</div>
</div>
<!-- Informations de contact -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Email</label>
<soft-input
:value="form.email"
@input="form.email = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.email }"
type="email"
placeholder="ex. contact@entreprise.com"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
@input="form.phone = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +33 1 23 45 67 89"
maxlength="50"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
</div>
</div>
</div>
<!-- Adresse de facturation -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Adresse ligne 1</label>
<soft-input
:value="form.billing_address_line1"
@input="form.billing_address_line1 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
type="text"
placeholder="ex. 123 Rue Principale"
maxlength="255"
/>
<div
v-if="fieldErrors.billing_address_line1"
class="invalid-feedback"
>
{{ fieldErrors.billing_address_line1 }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Adresse ligne 2</label>
<soft-input
:value="form.billing_address_line2"
@input="form.billing_address_line2 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
type="text"
placeholder="ex. Appartement, Suite, etc."
maxlength="255"
/>
<div
v-if="fieldErrors.billing_address_line2"
class="invalid-feedback"
>
{{ fieldErrors.billing_address_line2 }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-sm-4">
<label class="form-label">Code postal</label>
<soft-input
:value="form.billing_postal_code"
@input="form.billing_postal_code = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
type="text"
placeholder="ex. 75001"
maxlength="20"
/>
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
{{ fieldErrors.billing_postal_code }}
</div>
</div>
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
<label class="form-label">Ville</label>
<soft-input
:value="form.billing_city"
@input="form.billing_city = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_city }"
type="text"
placeholder="ex. Paris"
maxlength="191"
/>
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
{{ fieldErrors.billing_city }}
</div>
</div>
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
<label class="form-label">Code pays</label>
<select
:value="form.billing_country_code"
@input="form.billing_country_code = $event.target.value"
class="form-control multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
>
<option value="">Sélectionner un pays</option>
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="CH">Suisse</option>
<option value="LU">Luxembourg</option>
<option value="DE">Allemagne</option>
<option value="ES">Espagne</option>
<option value="IT">Italie</option>
<option value="GB">Royaume-Uni</option>
</select>
<div v-if="fieldErrors.billing_country_code" class="invalid-feedback">
{{ fieldErrors.billing_country_code }}
</div>
</div>
</div>
<!-- Notes -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Notes</label>
<textarea
:value="form.notes"
@input="form.notes = $event.target.value"
class="form-control multisteps-form__input"
rows="3"
placeholder="Notes supplémentaires sur le client..."
maxlength="1000"
></textarea>
</div>
</div>
<!-- Statut -->
<div class="row mt-3">
<div class="col-12">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="isActive"
:checked="form.is_active"
@change="form.is_active = $event.target.checked"
/>
<label class="form-check-label" for="isActive">
Client actif
</label>
</div>
</div>
</div>
<!-- Boutons -->
<div class="button-row d-flex mt-4">
<soft-button
type="button"
color="secondary"
variant="outline"
class="me-2 mb-0"
@click="resetForm"
>
Réinitialiser
</soft-button>
<soft-button
type="button"
color="dark"
variant="gradient"
class="ms-auto mb-0"
:disabled="props.loading"
@click="submitForm"
>
<span
v-if="props.loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{{ props.loading ? "Création..." : "Créer le client" }}
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
// Props
const props = defineProps({
categories: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
// Emits
const emit = defineEmits(["createClient"]);
// Reactive data
const errors = ref([]);
const fieldErrors = ref({});
const form = ref({
client_category_id: null,
type: "",
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,
});
// Watch for validation errors from parent
watch(
() => props.validationErrors,
(newErrors) => {
fieldErrors.value = { ...newErrors };
},
{ deep: true }
);
// Watch for success from parent
watch(
() => props.success,
(newSuccess) => {
if (newSuccess) {
resetForm();
}
}
);
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Clean up form data: convert empty strings to null
const cleanedForm = {};
const formData = form.value;
for (const [key, value] of Object.entries(formData)) {
if (value === "" || value === null || value === undefined) {
cleanedForm[key] = null;
} else {
cleanedForm[key] = value;
}
}
// Ensure is_active is boolean
cleanedForm.is_active = Boolean(formData.is_active);
// Remove type field if it's empty (not needed for backend)
if (!cleanedForm.type) {
delete cleanedForm.type;
}
console.log("Form data being emitted:", cleanedForm);
// Emit the cleaned form data to parent
emit("createClient", cleanedForm);
};
const resetForm = () => {
form.value = {
client_category_id: null,
type: "",
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,
};
clearErrors();
};
const clearErrors = () => {
errors.value = [];
fieldErrors.value = {};
};
</script>
<style scoped>
.form-label {
font-weight: 600;
margin-bottom: 0.5rem;
}
.text-danger {
color: #f5365c;
}
.invalid-feedback {
display: block;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
.alert {
border-radius: 0.75rem;
}
</style>

View File

@ -0,0 +1,16 @@
<template>
<div>
<soft-button color="success" variant="gradient">{{ text }}</soft-button>
</div>
</template>
<script setup>
import SoftButton from "@/components/SoftButton.vue";
import { defineProps } from "vue";
defineProps({
text: {
type: String,
default: "",
},
});
</script>

View File

@ -0,0 +1,23 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="client-new-action"></slot>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="client-other-action"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card mt-4">
<slot name="client-table"></slot>
</div>
</div>
</div>
</div>
</template>
<script></script>

View File

@ -0,0 +1,23 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="contact-new-action"></slot>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="contact-other-action"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card mt-4">
<slot name="contact-table"></slot>
</div>
</div>
</div>
</div>
</template>
<script></script>

View File

@ -0,0 +1,21 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="multisteps-form mb-5">
<div class="row">
<div class="col-12 col-lg-8 mx-auto my-5">
<slot name="multi-step" />
</div>
</div>
<!--form panels-->
<div class="row">
<div class="col-12 col-lg-8 m-auto">
<slot name="client-form" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -21,34 +21,6 @@
mini-icon="D"
text="Default"
/>
<sidenav-item
:to="{ name: 'Automotive' }"
mini-icon="A"
text="Automotive"
/>
<sidenav-item
:to="{ name: 'Smart Home' }"
mini-icon="S"
text="Smart Home"
/>
<sidenav-collapse-item
refer="vrExamples"
mini-icon="V"
text="Virtual Reality"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'VR Default' }"
mini-icon="V"
text="VR Default"
/>
<sidenav-item
:to="{ name: 'VR Info' }"
mini-icon="V"
text="VR Info"
/>
</template>
</sidenav-collapse-item>
<sidenav-item :to="{ name: 'CRM' }" mini-icon="C" text="CRM" />
</ul>
</template>
@ -59,7 +31,7 @@
class="text-xs ps-4 text-uppercase font-weight-bolder opacity-6"
:class="isRTL ? 'me-4' : 'ms-2'"
>
PAGES
CRM
</h6>
</li>
<li class="nav-item">
@ -77,395 +49,23 @@
<sidenav-collapse-item
refer="profileExample"
mini-icon="P"
text="Profile"
text="Client"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Profile Overview' }"
:to="{ name: 'Gestion contacts' }"
mini-icon="P"
text="Profile Overview"
text="Gestion Contact"
/>
<sidenav-item
:to="{ name: 'Teams' }"
:to="{ name: 'Gestion clients' }"
mini-icon="T"
text="Teams"
text="Gestion Clients"
/>
<sidenav-item
:to="{ name: 'All Projects' }"
:to="{ name: 'Localisation clients' }"
mini-icon="A"
text="All Projects"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="usersExample"
mini-icon="U"
text="Users"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Reports' }"
mini-icon="R"
text="Reports"
/>
<sidenav-item
:to="{ name: 'New User' }"
mini-icon="N"
text="New User"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="accountExample"
mini-icon="A"
text="Account"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Settings' }"
mini-icon="S"
text="Settings"
/>
<sidenav-item
:to="{ name: 'Billing' }"
mini-icon="B"
text="Billing"
/>
<sidenav-item
:to="{ name: 'Invoice' }"
mini-icon="I"
text="Invoice"
/>
<sidenav-item
:to="{ name: 'Security' }"
mini-icon="S"
text="Security"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="projectsExample"
mini-icon="P"
text="Projects"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'General' }"
mini-icon="G"
text="General"
/>
<sidenav-item
:to="{ name: 'Timeline' }"
mini-icon="T"
text="Timeline"
/>
<sidenav-item
:to="{ name: 'New Project' }"
mini-icon="N"
text="New Project"
/>
</template>
</sidenav-collapse-item>
<sidenav-item
:to="{ name: 'Pricing Page' }"
mini-icon="P"
text="Pricing Page"
/>
<sidenav-item :to="{ name: 'RTL' }" mini-icon="R" text="RTL" />
<sidenav-item
:to="{ name: 'Widgets' }"
mini-icon="W"
text="Widgets"
/>
<sidenav-item
:to="{ name: 'Charts' }"
mini-icon="C"
text="Charts"
/>
<sidenav-item
:to="{ name: 'Sweet Alerts' }"
mini-icon="S"
text="Sweet Alerts"
/>
<sidenav-item
:to="{ name: 'Notifications' }"
mini-icon="N"
text="Notifications"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<li class="nav-item">
<sidenav-collapse
collapse-ref="applicationsExamples"
nav-text="Applications"
:class="getRoute() === 'applications' ? 'active' : ''"
>
<template #icon>
<Settings />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<!-- nav links -->
<sidenav-item
:to="{ name: 'Kanban' }"
mini-icon="K"
text="Kanban"
/>
<sidenav-item
:to="{ name: 'Wizard' }"
mini-icon="W"
text="Wizard"
/>
<sidenav-item
:to="{ name: 'Data Tables' }"
mini-icon="D"
text="Data Tables"
/>
<sidenav-item
:to="{ name: 'Calendar' }"
mini-icon="C"
text="Calendar"
/>
<sidenav-item
:to="{ name: 'Analytics' }"
mini-icon="A"
text="Analytics"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<li class="nav-item">
<sidenav-collapse
collapse-ref="ecommerceExamples"
nav-text="Ecommerce"
:class="getRoute() === 'ecommerce' ? 'active' : ''"
>
<template #icon>
<Basket />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<!-- nav links -->
<sidenav-item
:to="{ name: 'Overview' }"
mini-icon="O"
text="Overview"
/>
<sidenav-collapse-item
refer="productsExample"
mini-icon="P"
text="Products"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'New Product' }"
mini-icon="N"
text="New Product"
/>
<sidenav-item
:to="{ name: 'Edit Product' }"
mini-icon="E"
text="Edit Product"
/>
<sidenav-item
:to="{ name: 'Product Page' }"
mini-icon="P"
text="Product page"
/>
<sidenav-item
:to="{ name: 'Products List' }"
mini-icon="P"
text="Products List"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="ordersExample"
mini-icon="O"
text="Orders"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Order List' }"
mini-icon="O"
text="Order List"
/>
<sidenav-item
:to="{ name: 'Order Details' }"
mini-icon="O"
text="Order Details"
/>
</template>
</sidenav-collapse-item>
<sidenav-item
:to="{ name: 'Referral' }"
mini-icon="R"
text="Referral"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<li class="nav-item">
<sidenav-collapse
collapse-ref="authExamples"
nav-text="Authentication"
:class="getRoute() === 'authentication' ? 'active' : ''"
>
<template #icon>
<Document />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<!-- nav links -->
<sidenav-collapse-item
refer="signinExample"
mini-icon="S"
text="Sign In"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Signin Basic' }"
mini-icon="B"
text="Basic"
/>
<sidenav-item
:to="{ name: 'Signin Cover' }"
mini-icon="C"
text="Cover"
/>
<sidenav-item
:to="{ name: 'Signin Illustration' }"
mini-icon="I"
text="Illustration"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="signupExample"
mini-icon="S"
text="Sign Up"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Signup Basic' }"
mini-icon="B"
text="Basic"
/>
<sidenav-item
:to="{ name: 'Signup Cover' }"
mini-icon="C"
text="Cover"
/>
<sidenav-item
:to="{ name: 'Signup Illustration' }"
mini-icon="I"
text="Illustration"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="resetExample"
mini-icon="R"
text="Reset Password"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Reset Basic' }"
mini-icon="B"
text="Basic"
/>
<sidenav-item
:to="{ name: 'Reset Cover' }"
mini-icon="C"
text="Cover"
/>
<sidenav-item
:to="{ name: 'Reset Illustration' }"
mini-icon="I"
text="Illustration"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="lockExample"
mini-icon="L"
text="Lock"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Lock Basic' }"
mini-icon="B"
text="Basic"
/>
<sidenav-item
:to="{ name: 'Lock Cover' }"
mini-icon="C"
text="Cover"
/>
<sidenav-item
:to="{ name: 'Lock Illustration' }"
mini-icon="I"
text="Illustration"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="StepExample"
mini-icon="2"
text="2-Step Verification"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Verification Basic' }"
mini-icon="B"
text="Basic"
/>
<sidenav-item
:to="{ name: 'Verification Cover' }"
mini-icon="C"
text="Cover"
/>
<sidenav-item
:to="{ name: 'Verification Illustration' }"
mini-icon="I"
text="Illustration"
/>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="errorExample"
mini-icon="E"
text="Error"
>
<template #nav-child-item>
<sidenav-item
:to="{ name: 'Error Error404' }"
mini-icon="E"
text="Error 404"
/>
<sidenav-item
:to="{ name: 'Error Error500' }"
mini-icon="E"
text="Error 500"
text="Localisation clients"
/>
</template>
</sidenav-collapse-item>
@ -473,353 +73,16 @@
</template>
</sidenav-collapse>
</li>
<li class="mt-3 nav-item">
<hr class="mt-0 horizontal dark" />
<h6
class="text-xs ps-4 ms-2 text-uppercase font-weight-bolder opacity-6"
:class="isRTL ? 'me-4' : 'ms-2'"
>
DOCS
</h6>
</li>
<li class="nav-item">
<sidenav-collapse
collapse-ref="basicExamples"
nav-text="Basic"
:class="getRoute() === 'basic' ? 'active' : ''"
>
<template #icon>
<Spaceship height="20px" />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<!-- nav links -->
<sidenav-collapse-item
refer="gettingStartedExample"
mini-icon="G"
text="Getting Started"
>
<template #nav-child-item>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/quick-start/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">Q</span>
<span class="sidenav-normal">Quick Start</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/license/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">L</span>
<span class="sidenav-normal">License</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/overview/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">C</span>
<span class="sidenav-normal">Contents</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/build-tools/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">B</span>
<span class="sidenav-normal">Build Tools</span>
</a>
</li>
</template>
</sidenav-collapse-item>
<sidenav-collapse-item
refer="foundationExample"
mini-icon="F"
text="Foundation"
>
<template #nav-child-item>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/colors/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">C</span>
<span class="sidenav-normal">Colors</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/grid/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">G</span>
<span class="sidenav-normal">Grid</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/typography/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">T</span>
<span class="sidenav-normal">Typography</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/icons/soft-ui-dashboard/"
target="_blank"
>
<span class="text-xs sidenav-mini-icon">I</span>
<span class="sidenav-normal">Icons</span>
</a>
</li>
</template>
</sidenav-collapse-item>
</ul>
</template>
</sidenav-collapse>
</li>
<li class="nav-item">
<sidenav-collapse
collapse-ref="componentsExamples"
nav-text="Components"
:class="getRoute() === 'components' ? 'active' : ''"
>
<template #icon>
<Box3d />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<!-- nav links -->
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/alerts/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">A</span>
<span class="sidenav-normal">Alerts</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/badge/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">B</span>
<span class="sidenav-normal">Badge</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/buttons/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">B</span>
<span class="sidenav-normal">Buttons</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/cards/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">C</span>
<span class="sidenav-normal">Card</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/carousel/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">C</span>
<span class="sidenav-normal">Carousel</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/collapse/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">C</span>
<span class="sidenav-normal">Collapse</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/dropdowns/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">D</span>
<span class="sidenav-normal">Dropdowns</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/forms/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">F</span>
<span class="sidenav-normal">Forms</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/modal/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">M</span>
<span class="sidenav-normal">Modal</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/navs/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">N</span>
<span class="sidenav-normal">Navs</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/navbar/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">N</span>
<span class="sidenav-normal">Navbar</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/pagination/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">P</span>
<span class="sidenav-normal">Pagination</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/popovers/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">P</span>
<span class="sidenav-normal">Popovers</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/progress/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">P</span>
<span class="sidenav-normal">Progress</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/spinners/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">S</span>
<span class="sidenav-normal">Spinners</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/tables/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">T</span>
<span class="sidenav-normal">Tables</span>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://www.creative-tim.com/learning-lab/bootstrap/tooltips/soft-ui-dashboard/"
target="_blank"
>
<span class="sidenav-mini-icon">T</span>
<span class="sidenav-normal">Tooltips</span>
</a>
</li>
</ul>
</template>
</sidenav-collapse>
</li>
<li class="nav-item">
<sidenav-collapse
nav-text="Changelog"
:collapse="false"
url="#"
:aria-controls="''"
collapse-ref="https://github.com/creativetimofficial/ct-soft-ui-dashboard-pro/blob/main/CHANGELOG.md"
>
<template #icon>
<CreditCard />
</template>
</sidenav-collapse>
</li>
</ul>
</div>
<div class="pt-3 mx-3 mt-3 sidenav-footer">
<sidenav-card
:class="cardBg"
text-primary="Need Help?"
text-secondary="Please check our docs"
route="https://www.creative-tim.com/learning-lab/vue/overview/soft-ui-dashboard/"
label="Documentation"
icon="ni ni-diamond"
/>
</div>
</template>
<script>
import SidenavItem from "./SidenavItem.vue";
import SidenavCollapse from "./SidenavCollapse.vue";
import SidenavCard from "./SidenavCard.vue";
import SidenavCollapseItem from "./SidenavCollapseItem.vue";
import Settings from "../../components/Icon/Settings.vue";
import Basket from "../../components/Icon/Basket.vue";
import Box3d from "../../components/Icon/Box3d.vue";
import Shop from "../../components/Icon/Shop.vue";
import Office from "../../components/Icon/Office.vue";
import Document from "../../components/Icon/Document.vue";
import Spaceship from "../../components/Icon/Spaceship.vue";
import CreditCard from "../../components/Icon/CreditCard.vue";
import { mapState } from "vuex";
export default {
@ -827,16 +90,9 @@ export default {
components: {
SidenavItem,
SidenavCollapse,
SidenavCard,
SidenavCollapseItem,
Settings,
Basket,
Box3d,
Shop,
Office,
Document,
Spaceship,
CreditCard,
},
props: {
cardBg: {

View File

@ -1,5 +1,5 @@
import { createPinia } from 'pinia'
import { createPinia } from "pinia";
// Single shared Pinia instance so router guards and app use the same store
export const pinia = createPinia()
export default pinia
export const pinia = createPinia();
export default pinia;

View File

@ -365,6 +365,26 @@ const routes = [
component: Error500,
meta: { guestLayout: true },
},
{
path: "/crm/contact",
name: "Gestion contacts",
component: () => import("@/views/pages/CRM/Contacts.vue"),
},
{
path: "/crm/localisation-clients",
name: "Localisation clients",
component: () => import("@/views/pages/CRM/LocalisationClients.vue"),
},
{
path: "/crm/clients",
name: "Gestion clients",
component: () => import("@/views/pages/CRM/Clients.vue"),
},
{
path: "/crm/new-client",
name: "Creation client",
component: () => import("@/views/pages/CRM/AddClient.vue"),
},
];
const router = createRouter({

View File

@ -37,12 +37,12 @@ export const AuthService = {
method: "post",
data: payload,
});
// Save token to localStorage
if (response.success && response.data.token) {
localStorage.setItem("auth_token", response.data.token);
}
return response;
},
@ -52,12 +52,12 @@ export const AuthService = {
method: "post",
data: payload,
});
// Save token to localStorage (user is automatically logged in after registration)
if (response.success && response.data.token) {
localStorage.setItem("auth_token", response.data.token);
}
return response;
},
@ -72,18 +72,21 @@ export const AuthService = {
async me() {
// Fetch current user from API
const response = await request<any>({ url: "/api/auth/user", method: "get" });
const response = await request<any>({
url: "/api/auth/user",
method: "get",
});
// Handle both direct user response and wrapped response
if (response.success && response.data) {
return response.data;
}
return response;
},
getToken(): string | null {
return localStorage.getItem("auth_token");
},
hasToken(): boolean {
return !!this.getToken();
},

View File

@ -0,0 +1,258 @@
import { request } from "./http";
export interface ClientCategory {
id: number;
name: string;
slug: string;
description: string | null;
is_active: boolean;
sort_order: number | null;
created_at: string;
updated_at: string;
clients_count?: number; // Optional field if you include counts in responses
}
export interface ClientCategoryListResponse {
data: ClientCategory[];
meta?: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
}
export interface ClientCategoryResponse {
data: ClientCategory;
}
export interface CreateClientCategoryPayload {
name: string;
slug?: string; // Optional, might be auto-generated on backend
description?: string | null;
is_active?: boolean;
sort_order?: number | null;
}
export interface UpdateClientCategoryPayload
extends Partial<CreateClientCategoryPayload> {
id: number;
}
export const ClientCategoryService = {
/**
* Get all client categories with pagination
*/
async getAllCategories(params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
sort_by?: string;
sort_order?: "asc" | "desc";
}): Promise<ClientCategoryListResponse> {
const response = await request<ClientCategoryListResponse>({
url: "/api/client-categories",
method: "get",
params,
});
return response;
},
/**
* Get all active client categories
*/
async getActiveCategories(params?: {
page?: number;
per_page?: number;
sort_by?: string;
sort_order?: "asc" | "desc";
}): Promise<ClientCategoryListResponse> {
const response = await request<ClientCategoryListResponse>({
url: "/api/client-categories",
method: "get",
params: {
is_active: true,
...params,
},
});
return response;
},
/**
* Get a specific client category by ID
*/
async getCategory(id: number): Promise<ClientCategoryResponse> {
const response = await request<ClientCategoryResponse>({
url: `/api/client-categories/${id}`,
method: "get",
});
return response;
},
/**
* Get a specific client category by slug
*/
async getCategoryBySlug(slug: string): Promise<ClientCategoryResponse> {
const response = await request<ClientCategoryResponse>({
url: `/api/client-categories/slug/${slug}`,
method: "get",
});
return response;
},
/**
* Create a new client category
*/
async createCategory(
payload: CreateClientCategoryPayload
): Promise<ClientCategoryResponse> {
const formattedPayload = this.transformCategoryPayload(payload);
const response = await request<ClientCategoryResponse>({
url: "/api/client-categories",
method: "post",
data: formattedPayload,
});
return response;
},
/**
* Update an existing client category
*/
async updateCategory(
payload: UpdateClientCategoryPayload
): Promise<ClientCategoryResponse> {
const { id, ...updateData } = payload;
const formattedPayload = this.transformCategoryPayload(updateData);
const response = await request<ClientCategoryResponse>({
url: `/api/client-categories/${id}`,
method: "put",
data: formattedPayload,
});
return response;
},
/**
* Delete a client category
*/
async deleteCategory(
id: number
): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: `/api/client-categories/${id}`,
method: "delete",
});
return response;
},
/**
* Transform category payload to match Laravel form request structure
*/
transformCategoryPayload(payload: Partial<CreateClientCategoryPayload>): any {
const transformed: any = { ...payload };
// Ensure boolean values are properly formatted
if (typeof transformed.is_active === "boolean") {
transformed.is_active = transformed.is_active ? 1 : 0;
}
// Remove undefined values to avoid sending them
Object.keys(transformed).forEach((key) => {
if (transformed[key] === undefined) {
delete transformed[key];
}
});
return transformed;
},
/**
* Search client categories by name or description
*/
async searchCategories(
query: string,
params?: {
page?: number;
per_page?: number;
is_active?: boolean;
}
): Promise<ClientCategoryListResponse> {
const response = await request<ClientCategoryListResponse>({
url: "/api/client-categories",
method: "get",
params: {
search: query,
...params,
},
});
return response;
},
/**
* Toggle category active status
*/
async toggleCategoryStatus(
id: number,
isActive: boolean
): Promise<ClientCategoryResponse> {
const response = await request<ClientCategoryResponse>({
url: `/api/client-categories/${id}/status`,
method: "patch",
data: {
is_active: isActive,
},
});
return response;
},
/**
* Get clients for a specific category
*/
async getCategoryClients(
categoryId: number,
params?: {
page?: number;
per_page?: number;
is_active?: boolean;
}
): Promise<any> {
// You can import and use ClientListResponse type here
const response = await request<any>({
url: `/api/client-categories/${categoryId}/clients`,
method: "get",
params,
});
return response;
},
/**
* Reorder categories
*/
async reorderCategories(
orderedIds: number[]
): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: "/api/client-categories/reorder",
method: "post",
data: {
order: orderedIds,
},
});
return response;
},
};
export default ClientCategoryService;

View File

@ -0,0 +1,225 @@
import { request } from "./http";
export interface ClientAddress {
line1: string | null;
line2: string | null;
postal_code: string | null;
city: string | null;
country_code: string | null;
full_address?: string;
}
export interface Client {
id: number;
client_category_id: number | null;
type_label?: string;
name: string;
vat_number: string | null;
siret: string | null;
email: string | null;
phone: string | null;
billing_address: ClientAddress;
group_id: number | null;
notes: string | null;
is_active: boolean;
default_tva_rate_id: number | null;
created_at: string;
updated_at: string;
}
export interface ClientListResponse {
data: Client[];
meta?: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
}
export interface ClientResponse {
data: Client;
}
export interface CreateClientPayload {
client_category_id?: number | null;
name: string;
vat_number?: string | null;
siret?: string | null;
email?: string | null;
phone?: string | null;
billing_address_line1?: string | null;
billing_address_line2?: string | null;
billing_postal_code?: string | null;
billing_city?: string | null;
billing_country_code?: string | null;
group_id?: number | null;
notes?: string | null;
is_active?: boolean;
default_tva_rate_id?: number | null;
}
export interface UpdateClientPayload extends Partial<CreateClientPayload> {
id: number;
}
export const ClientService = {
/**
* Get all clients with pagination
*/
async getAllClients(params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
group_id?: number;
}): Promise<ClientListResponse> {
const response = await request<ClientListResponse>({
url: "/api/clients",
method: "get",
params,
});
return response;
},
/**
* Get a specific client by ID
*/
async getClient(id: number): Promise<ClientResponse> {
const response = await request<ClientResponse>({
url: `/api/clients/${id}`,
method: "get",
});
return response;
},
/**
* Create a new client
*/
async createClient(payload: CreateClientPayload): Promise<ClientResponse> {
// Transform the payload to match Laravel form request expectations
const formattedPayload = this.transformClientPayload(payload);
const response = await request<ClientResponse>({
url: "/api/clients",
method: "post",
data: formattedPayload,
});
return response;
},
/**
* Update an existing client
*/
async updateClient(payload: UpdateClientPayload): Promise<ClientResponse> {
const { id, ...updateData } = payload;
const formattedPayload = this.transformClientPayload(updateData);
const response = await request<ClientResponse>({
url: `/api/clients/${id}`,
method: "put",
data: formattedPayload,
});
return response;
},
/**
* Delete a client
*/
async deleteClient(
id: number
): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: `/api/clients/${id}`,
method: "delete",
});
return response;
},
/**
* Transform client payload to match Laravel form request structure
*/
transformClientPayload(payload: Partial<CreateClientPayload>): any {
const transformed: any = { ...payload };
// Ensure boolean values are properly formatted
if (typeof transformed.is_active === "boolean") {
transformed.is_active = transformed.is_active ? 1 : 0;
}
// Remove undefined values to avoid sending them
Object.keys(transformed).forEach((key) => {
if (transformed[key] === undefined) {
delete transformed[key];
}
});
return transformed;
},
/**
* Search clients by name, email, or other criteria
*/
async searchClients(
query: string,
params?: {
page?: number;
per_page?: number;
}
): Promise<ClientListResponse> {
const response = await request<ClientListResponse>({
url: "/api/clients",
method: "get",
params: {
search: query,
...params,
},
});
return response;
},
/**
* Get active clients only
*/
async getActiveClients(params?: {
page?: number;
per_page?: number;
}): Promise<ClientListResponse> {
const response = await request<ClientListResponse>({
url: "/api/clients",
method: "get",
params: {
is_active: true,
...params,
},
});
return response;
},
/**
* Toggle client active status
*/
async toggleClientStatus(
id: number,
isActive: boolean
): Promise<ClientResponse> {
const response = await request<ClientResponse>({
url: `/api/clients/${id}/status`,
method: "patch",
data: {
is_active: isActive,
},
});
return response;
},
};
export default ClientService;

View File

View File

@ -16,15 +16,20 @@ export const useAuthStore = defineStore("auth", {
try {
const userData = await AuthService.me();
// Validate that we actually received a user object, not HTML or other invalid data
if (userData && typeof userData === 'object' && 'id' in userData && 'email' in userData) {
if (
userData &&
typeof userData === "object" &&
"id" in userData &&
"email" in userData
) {
this.user = userData;
} else {
// If backend returned invalid data (like HTML), treat as unauthenticated
console.warn('Invalid user data received from /api/user:', userData);
console.warn("Invalid user data received from /api/user:", userData);
this.user = null;
}
} catch (e) {
console.error('Error fetching user:', e);
console.error("Error fetching user:", e);
this.user = null;
this.token = null;
} finally {
@ -34,7 +39,7 @@ export const useAuthStore = defineStore("auth", {
},
async login(payload: LoginPayload) {
const response = await AuthService.login(payload);
if (response.success && response.data.user && response.data.token) {
this.user = response.data.user;
this.token = response.data.token;
@ -46,7 +51,7 @@ export const useAuthStore = defineStore("auth", {
},
async register(payload: RegisterPayload) {
const response = await AuthService.register(payload);
if (response.success && response.data.user && response.data.token) {
this.user = response.data.user;
this.token = response.data.token;

View File

@ -0,0 +1,359 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import ClientCategoryService from "@/services/categories_client";
import type {
ClientCategory,
CreateClientCategoryPayload,
UpdateClientCategoryPayload,
} from "@/services/categories_client";
export const useClientCategoryStore = defineStore("clientCategory", () => {
// State
const categories = ref<ClientCategory[]>([]);
const currentCategory = ref<ClientCategory | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const allCategories = computed(() => categories.value);
const activeCategories = computed(() =>
categories.value.filter((category) => category.is_active)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getCategoryById = computed(() => (id: number) =>
categories.value.find((category) => category.id === id)
);
const getCategoryBySlug = computed(() => (slug: string) =>
categories.value.find((category) => category.slug === slug)
);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setCategories = (newCategories: ClientCategory[]) => {
categories.value = newCategories;
};
const setCurrentCategory = (category: ClientCategory | null) => {
currentCategory.value = category;
};
/**
* Fetch all client categories
*/
const fetchCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.getAllCategories({
per_page: 1000, // Get all categories without pagination
});
setCategories(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch categories";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch active client categories only
*/
const fetchActiveCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.getActiveCategories({
per_page: 1000, // Get all active categories without pagination
});
setCategories(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch active categories";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch a single category by ID
*/
const fetchCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.getCategory(id);
setCurrentCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch category";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch a single category by slug
*/
const fetchCategoryBySlug = async (slug: string) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.getCategoryBySlug(slug);
setCurrentCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch category by slug";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Create a new category
*/
const createCategory = async (payload: CreateClientCategoryPayload) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.createCategory(payload);
// Add the new category to the list
categories.value.push(response.data);
setCurrentCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to create category";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Update an existing category
*/
const updateCategory = async (payload: UpdateClientCategoryPayload) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.updateCategory(payload);
const updatedCategory = response.data;
// Update in the categories list
const index = categories.value.findIndex(
(category) => category.id === updatedCategory.id
);
if (index !== -1) {
categories.value[index] = updatedCategory;
}
// Update current category if it's the one being edited
if (
currentCategory.value &&
currentCategory.value.id === updatedCategory.id
) {
setCurrentCategory(updatedCategory);
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to update category";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Delete a category
*/
const deleteCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.deleteCategory(id);
// Remove from the categories list
categories.value = categories.value.filter(
(category) => category.id !== id
);
// Clear current category if it's the one being deleted
if (currentCategory.value && currentCategory.value.id === id) {
setCurrentCategory(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to delete category";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Toggle category active status
*/
const toggleCategoryStatus = async (id: number, isActive: boolean) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.toggleCategoryStatus(
id,
isActive
);
const updatedCategory = response.data;
// Update in the categories list
const index = categories.value.findIndex(
(category) => category.id === id
);
if (index !== -1) {
categories.value[index] = updatedCategory;
}
// Update current category if it's the one being toggled
if (currentCategory.value && currentCategory.value.id === id) {
setCurrentCategory(updatedCategory);
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to toggle category status";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Search categories by name or description
*/
const searchCategories = async (query: string) => {
setLoading(true);
setError(null);
try {
const response = await ClientCategoryService.searchCategories(query, {
per_page: 1000, // Get all search results without pagination
});
setCategories(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to search categories";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Clear current category
*/
const clearCurrentCategory = () => {
setCurrentCategory(null);
};
/**
* Clear all state
*/
const clearStore = () => {
categories.value = [];
currentCategory.value = null;
error.value = null;
};
return {
// State
categories,
currentCategory,
loading,
error,
// Getters
allCategories,
activeCategories,
isLoading,
hasError,
getError,
getCategoryById,
getCategoryBySlug,
// Actions
fetchCategories,
fetchActiveCategories,
fetchCategory,
fetchCategoryBySlug,
createCategory,
updateCategory,
deleteCategory,
toggleCategoryStatus,
searchCategories,
clearCurrentCategory,
clearStore,
clearError,
};
});

View File

@ -0,0 +1,331 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import ClientService from "@/services/client";
import type {
Client,
CreateClientPayload,
UpdateClientPayload,
ClientListResponse,
} from "@/services/client";
export const useClientStore = defineStore("client", () => {
// State
const clients = ref<Client[]>([]);
const currentClient = ref<Client | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Pagination state
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
});
// Getters
const allClients = computed(() => clients.value);
const activeClients = computed(() =>
clients.value.filter((client) => client.is_active)
);
const inactiveClients = computed(() =>
clients.value.filter((client) => !client.is_active)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getClientById = computed(() => (id: number) =>
clients.value.find((client) => client.id === id)
);
const getPagination = computed(() => pagination.value);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setClients = (newClients: Client[]) => {
clients.value = newClients;
};
const setCurrentClient = (client: Client | null) => {
currentClient.value = client;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
current_page: meta.current_page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || 10,
total: meta.total || 0,
};
}
};
/**
* Fetch all clients with optional pagination and filters
*/
const fetchClients = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
group_id?: number;
}) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.getAllClients(params);
setClients(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to fetch clients";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch a single client by ID
*/
const fetchClient = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.getClient(id);
setCurrentClient(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to fetch client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Create a new client
*/
const createClient = async (payload: CreateClientPayload) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.createClient(payload);
// Add the new client to the list
clients.value.push(response.data);
setCurrentClient(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to create client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Update an existing client
*/
const updateClient = async (payload: UpdateClientPayload) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.updateClient(payload);
const updatedClient = response.data;
// Update in the clients list
const index = clients.value.findIndex(
(client) => client.id === updatedClient.id
);
if (index !== -1) {
clients.value[index] = updatedClient;
}
// Update current client if it's the one being edited
if (currentClient.value && currentClient.value.id === updatedClient.id) {
setCurrentClient(updatedClient);
}
return updatedClient;
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to update client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Delete a client
*/
const deleteClient = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.deleteClient(id);
// Remove from the clients list
clients.value = clients.value.filter((client) => client.id !== id);
// Clear current client if it's the one being deleted
if (currentClient.value && currentClient.value.id === id) {
setCurrentClient(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to delete client";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Search clients
*/
const searchClients = async (
query: string,
params?: {
page?: number;
per_page?: number;
}
) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.searchClients(query, params);
setClients(response.data);
if (response.meta) {
setPagination(response.meta);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to search clients";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Toggle client active status
*/
const toggleClientStatus = async (id: number, isActive: boolean) => {
setLoading(true);
setError(null);
try {
const response = await ClientService.toggleClientStatus(id, isActive);
const updatedClient = response.data;
// Update in the clients list
const index = clients.value.findIndex((client) => client.id === id);
if (index !== -1) {
clients.value[index] = updatedClient;
}
// Update current client if it's the one being toggled
if (currentClient.value && currentClient.value.id === id) {
setCurrentClient(updatedClient);
}
return updatedClient;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to toggle client status";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Clear current client
*/
const clearCurrentClient = () => {
setCurrentClient(null);
};
/**
* Clear all state
*/
const clearStore = () => {
clients.value = [];
currentClient.value = null;
error.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
};
};
return {
// State
clients,
currentClient,
loading,
error,
// Getters
allClients,
activeClients,
inactiveClients,
isLoading,
hasError,
getError,
getClientById,
getPagination,
// Actions
fetchClients,
fetchClient,
createClient,
updateClient,
deleteClient,
searchClients,
toggleClientStatus,
clearCurrentClient,
clearStore,
clearError,
};
});

View File

@ -0,0 +1,59 @@
<template>
<add-client-presentation
:categories="categoryClientStore.activeCategories"
:loading="clientStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-client="handleCreateClient"
/>
</template>
<script setup>
import AddClientPresentation from "@/components/Organism/CRM/AddClientPresentation.vue";
import { useClientCategoryStore } from "@/stores/clientCategorie.store";
import { useClientStore } from "@/stores/clientStore";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const categoryClientStore = useClientCategoryStore();
const clientStore = useClientStore();
const validationErrors = ref({});
const showSuccess = ref(false);
onMounted(async () => {
await categoryClientStore.fetchActiveCategories();
});
const handleCreateClient = async (form) => {
try {
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create client
const client = await clientStore.createClient(form);
// Show success message
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Clients" });
}, 2000);
} catch (error) {
console.error("Error creating client:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
alert(errorMessage);
} else {
alert("Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,16 @@
<template>
<client-presentation
:client-data="clientStore.clients"
:loading-data="clientStore.loading"
/>
</template>
<script setup>
import ClientPresentation from "@/components/Organism/CRM/ClientPresentation.vue";
import { useClientStore } from "@/stores/clientStore";
import { onMounted } from "vue";
const clientStore = useClientStore();
onMounted(async () => {
await clientStore.fetchClients();
});
</script>

View File

@ -0,0 +1,6 @@
<template>
<contact-presentation />
</template>
<script setup>
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";
</script>

View File

@ -0,0 +1,3 @@
<template>
<h1>GECTION localisation clients</h1>
</template>

View File

@ -76,24 +76,6 @@
{{ isLoading ? "Connexion..." : "Se connecter" }}
</SoftButton>
</div>
<div class="mb-2 text-center position-relative">
<p
class="px-3 mb-2 text-sm bg-white font-weight-bold text-secondary text-border d-inline z-index-2"
>
ou
</p>
</div>
<div class="text-center">
<SoftButton
class="mt-2 mb-4"
variant="gradient"
color="dark"
full-width
@click="$router.push('/register')"
>
Creer un compte
</SoftButton>
</div>
</form>
</div>
</div>