client liste et formulaire
This commit is contained in:
parent
215f4c4071
commit
175446adbe
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
70
thanasoft-back/app/Http/Requests/ClientCategoryRequest.php
Normal file
70
thanasoft-back/app/Http/Requests/ClientCategoryRequest.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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é.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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é.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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' => [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
namespace App\Http\Resources\Client;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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é';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
29
thanasoft-back/app/Models/ClientCategory.php
Normal file
29
thanasoft-back/app/Models/ClientCategory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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' => [],
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
|
||||
});
|
||||
|
||||
271
thanasoft-front/CLIENT_CREATION_FLOW.md
Normal file
271
thanasoft-front/CLIENT_CREATION_FLOW.md
Normal 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
|
||||
205
thanasoft-front/CONTACT_TABLE_FIX.md
Normal file
205
thanasoft-front/CONTACT_TABLE_FIX.md
Normal 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`.
|
||||
286
thanasoft-front/PAYLOAD_CLEANING.md
Normal file
286
thanasoft-front/PAYLOAD_CLEANING.md
Normal 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.
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
526
thanasoft-front/src/components/molecules/Tables/ContactTable.vue
Normal file
526
thanasoft-front/src/components/molecules/Tables/ContactTable.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
441
thanasoft-front/src/components/molecules/form/NewClientForm.vue
Normal file
441
thanasoft-front/src/components/molecules/form/NewClientForm.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
258
thanasoft-front/src/services/categories_client.ts
Normal file
258
thanasoft-front/src/services/categories_client.ts
Normal 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;
|
||||
225
thanasoft-front/src/services/client.ts
Normal file
225
thanasoft-front/src/services/client.ts
Normal 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;
|
||||
0
thanasoft-front/src/services/contact.ts
Normal file
0
thanasoft-front/src/services/contact.ts
Normal 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;
|
||||
|
||||
359
thanasoft-front/src/stores/clientCategorie.store.ts
Normal file
359
thanasoft-front/src/stores/clientCategorie.store.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
331
thanasoft-front/src/stores/clientStore.ts
Normal file
331
thanasoft-front/src/stores/clientStore.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
59
thanasoft-front/src/views/pages/CRM/AddClient.vue
Normal file
59
thanasoft-front/src/views/pages/CRM/AddClient.vue
Normal 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>
|
||||
16
thanasoft-front/src/views/pages/CRM/Clients.vue
Normal file
16
thanasoft-front/src/views/pages/CRM/Clients.vue
Normal 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>
|
||||
6
thanasoft-front/src/views/pages/CRM/Contacts.vue
Normal file
6
thanasoft-front/src/views/pages/CRM/Contacts.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<contact-presentation />
|
||||
</template>
|
||||
<script setup>
|
||||
import ContactPresentation from "@/components/Organism/CRM/ContactPresentation.vue";
|
||||
</script>
|
||||
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h1>GECTION localisation clients</h1>
|
||||
</template>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user