FEAT: activite client=
This commit is contained in:
parent
503fb0d008
commit
c00ce5ab94
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||||
|
use App\Http\Resources\Client\ClientActivityTimelineResource;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ClientActivityTimelineController extends Controller
|
||||||
|
{
|
||||||
|
protected $repository;
|
||||||
|
|
||||||
|
public function __construct(ClientActivityTimelineRepositoryInterface $repository)
|
||||||
|
{
|
||||||
|
$this->repository = $repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity timeline for a client
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Client $client)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$perPage = (int) $request->get('per_page', 10);
|
||||||
|
|
||||||
|
$activities = $this->repository->getByClient($client->id, $perPage);
|
||||||
|
|
||||||
|
return ClientActivityTimelineResource::collection($activities);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error fetching client timeline: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ class ClientController extends Controller
|
|||||||
public function index(Request $request): ClientCollection|JsonResponse
|
public function index(Request $request): ClientCollection|JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$perPage = $request->get('per_page', 15);
|
$perPage = (int) $request->get('per_page', 15);
|
||||||
$filters = [
|
$filters = [
|
||||||
'search' => $request->get('search'),
|
'search' => $request->get('search'),
|
||||||
'is_active' => $request->get('is_active'),
|
'is_active' => $request->get('is_active'),
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Client;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ClientActivityTimelineResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'actor_type' => $this->actor_type,
|
||||||
|
'actor_name' => $this->actor ? $this->actor->firstname . ' ' . $this->actor->lastname : 'System',
|
||||||
|
'event_type' => $this->event_type,
|
||||||
|
'entity_type' => $this->entity_type,
|
||||||
|
'entity_id' => $this->entity_id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'description' => $this->description,
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||||
|
'time_ago' => $this->created_at?->diffForHumans(),
|
||||||
|
|
||||||
|
// Helper properties for frontend display
|
||||||
|
'icon' => $this->getIcon(),
|
||||||
|
'color' => $this->getColor(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getIcon()
|
||||||
|
{
|
||||||
|
// Map event types to icons (using Nucleo icons as requested)
|
||||||
|
return match($this->event_type) {
|
||||||
|
'call' => 'mobile-button',
|
||||||
|
'email_sent', 'email_received' => 'email-83',
|
||||||
|
'invoice_created', 'invoice_sent', 'invoice_paid' => 'money-coins',
|
||||||
|
'quote_created', 'quote_sent' => 'single-copy-04',
|
||||||
|
'file_uploaded', 'attachment_sent', 'attachment_received' => 'cloud-upload-96',
|
||||||
|
'client_created' => 'badge-24',
|
||||||
|
'meeting', 'intervention_created' => 'laptop',
|
||||||
|
default => 'bell-55'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getColor()
|
||||||
|
{
|
||||||
|
// Map event types to colors
|
||||||
|
return match($this->event_type) {
|
||||||
|
'client_created' => 'success',
|
||||||
|
'call' => 'info',
|
||||||
|
'email_sent' => 'success',
|
||||||
|
'invoice_paid' => 'success',
|
||||||
|
'invoice_created' => 'warning',
|
||||||
|
'quote_created' => 'primary',
|
||||||
|
'quote_sent' => 'primary',
|
||||||
|
default => 'dark'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,9 +4,11 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class Client extends Model
|
class Client extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'vat_number',
|
'vat_number',
|
||||||
|
|||||||
47
thanasoft-back/app/Models/ClientActivityTimeline.php
Normal file
47
thanasoft-back/app/Models/ClientActivityTimeline.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class ClientActivityTimeline extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'client_activity_timeline';
|
||||||
|
public $timestamps = false; // Only using created_at
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'client_id',
|
||||||
|
'actor_type',
|
||||||
|
'actor_user_id',
|
||||||
|
'event_type',
|
||||||
|
'entity_type',
|
||||||
|
'entity_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'metadata',
|
||||||
|
'created_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function client()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Client::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actor()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'actor_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polymorphic relation to entity if we had models for all of them
|
||||||
|
// public function entity()
|
||||||
|
// {
|
||||||
|
// return $this->morphTo();
|
||||||
|
// }
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@ -13,7 +14,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
// Repository interface to implementation bindings
|
// Repository interface to implementation bindings
|
||||||
$this->app->bind(\App\Repositories\ClientRepositoryInterface::class, function ($app) {
|
$this->app->bind(\App\Repositories\ClientRepositoryInterface::class, function ($app) {
|
||||||
return new \App\Repositories\ClientRepository($app->make(\App\Models\Client::class));
|
return new \App\Repositories\ClientRepository(
|
||||||
|
$app->make(\App\Models\Client::class),
|
||||||
|
$app->make(\App\Repositories\ClientActivityTimelineRepositoryInterface::class)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\ClientGroupRepositoryInterface::class, function ($app) {
|
$this->app->bind(\App\Repositories\ClientGroupRepositoryInterface::class, function ($app) {
|
||||||
@ -67,14 +71,30 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, \App\Repositories\ProductCategoryRepository::class);
|
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, \App\Repositories\ProductCategoryRepository::class);
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\InvoiceRepositoryInterface::class, \App\Repositories\InvoiceRepository::class);
|
$this->app->bind(\App\Repositories\InvoiceRepositoryInterface::class, function ($app) {
|
||||||
|
return new \App\Repositories\InvoiceRepository(
|
||||||
|
$app->make(\App\Models\Invoice::class),
|
||||||
|
$app->make(\App\Repositories\InvoiceLineRepositoryInterface::class),
|
||||||
|
$app->make(\App\Repositories\ClientActivityTimelineRepositoryInterface::class)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class);
|
$this->app->bind(\App\Repositories\InvoiceLineRepositoryInterface::class, \App\Repositories\InvoiceLineRepository::class);
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, \App\Repositories\QuoteRepository::class);
|
$this->app->bind(\App\Repositories\QuoteRepositoryInterface::class, function ($app) {
|
||||||
|
return new \App\Repositories\QuoteRepository(
|
||||||
|
$app->make(\App\Models\Quote::class),
|
||||||
|
$app->make(\App\Repositories\QuoteLineRepositoryInterface::class),
|
||||||
|
$app->make(\App\Repositories\InvoiceRepositoryInterface::class),
|
||||||
|
$app->make(\App\Repositories\ClientActivityTimelineRepositoryInterface::class)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
||||||
|
|
||||||
|
$this->app->bind(\App\Repositories\QuoteLineRepositoryInterface::class, \App\Repositories\QuoteLineRepository::class);
|
||||||
|
|
||||||
|
$this->app->bind(\App\Repositories\ClientActivityTimelineRepositoryInterface::class, \App\Repositories\ClientActivityTimelineRepository::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -84,6 +104,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Schema::defaultStringLength(191);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use App\Models\ClientActivityTimeline;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class ClientActivityTimelineRepository extends BaseRepository implements ClientActivityTimelineRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ClientActivityTimeline $model)
|
||||||
|
{
|
||||||
|
parent::__construct($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated timeline for a specific client
|
||||||
|
*
|
||||||
|
* @param int $clientId
|
||||||
|
* @param int $perPage
|
||||||
|
* @return LengthAwarePaginator
|
||||||
|
*/
|
||||||
|
public function getByClient(int $clientId, int $perPage = 15): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->model
|
||||||
|
->where('client_id', $clientId)
|
||||||
|
->with('actor') // Load actor relationship
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a new activity
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return ClientActivityTimeline
|
||||||
|
*/
|
||||||
|
public function logActivity(array $data)
|
||||||
|
{
|
||||||
|
return $this->create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
interface ClientActivityTimelineRepositoryInterface extends BaseRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get paginated timeline for a specific client
|
||||||
|
*
|
||||||
|
* @param int $clientId
|
||||||
|
* @param int $perPage
|
||||||
|
* @return LengthAwarePaginator
|
||||||
|
*/
|
||||||
|
public function getByClient(int $clientId, int $perPage = 15): LengthAwarePaginator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a new activity
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function logActivity(array $data);
|
||||||
|
}
|
||||||
@ -8,13 +8,44 @@ use App\Models\Client;
|
|||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||||
|
use Illuminate\Support\Facades\Log as LaravelLog;
|
||||||
|
|
||||||
class ClientRepository extends BaseRepository implements ClientRepositoryInterface
|
class ClientRepository extends BaseRepository implements ClientRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(Client $model)
|
public function __construct(
|
||||||
{
|
Client $model,
|
||||||
|
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||||
|
) {
|
||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new client and log activity
|
||||||
|
*/
|
||||||
|
public function create(array $attributes): Client
|
||||||
|
{
|
||||||
|
$client = parent::create($attributes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->timelineRepository->logActivity([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'actor_user_id' => auth()->id(),
|
||||||
|
'event_type' => 'client_created',
|
||||||
|
'entity_type' => 'client',
|
||||||
|
'entity_id' => $client->id,
|
||||||
|
'title' => 'Nouveau client créé',
|
||||||
|
'description' => "Le client {$client->name} a été ajouté au système.",
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
LaravelLog::error("Failed to log client creation activity: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get paginated clients
|
* Get paginated clients
|
||||||
|
|||||||
@ -6,8 +6,14 @@ use App\Models\Intervention;
|
|||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class InterventionRepository implements InterventionRepositoryInterface
|
class InterventionRepository implements InterventionRepositoryInterface
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all interventions with optional filtering and pagination
|
* Get all interventions with optional filtering and pagination
|
||||||
*
|
*
|
||||||
@ -88,7 +94,25 @@ class InterventionRepository implements InterventionRepositoryInterface
|
|||||||
public function create(array $data): Intervention
|
public function create(array $data): Intervention
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
return DB::transaction(function () use ($data) {
|
||||||
return Intervention::create($data);
|
$intervention = Intervention::create($data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->timelineRepository->logActivity([
|
||||||
|
'client_id' => $intervention->client_id,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'actor_user_id' => auth()->id(),
|
||||||
|
'event_type' => 'intervention_created',
|
||||||
|
'entity_type' => 'intervention',
|
||||||
|
'entity_id' => $intervention->id,
|
||||||
|
'title' => 'Nouvelle intervention créée',
|
||||||
|
'description' => "Une intervention de type '{$intervention->type}' a été créée.",
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to log intervention creation activity: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $intervention;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,11 +9,14 @@ use App\Models\Quote;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||||
|
|
||||||
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
|
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Invoice $model,
|
Invoice $model,
|
||||||
protected InvoiceLineRepositoryInterface $invoiceLineRepository
|
protected InvoiceLineRepositoryInterface $invoiceLineRepository,
|
||||||
|
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||||
) {
|
) {
|
||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
@ -64,6 +67,22 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
|
|||||||
// Record history
|
// Record history
|
||||||
$this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference);
|
$this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->timelineRepository->logActivity([
|
||||||
|
'client_id' => $invoice->client_id,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'actor_user_id' => auth()->id(),
|
||||||
|
'event_type' => 'invoice_created',
|
||||||
|
'entity_type' => 'invoice',
|
||||||
|
'entity_id' => $invoice->id,
|
||||||
|
'title' => 'Nouvelle facture créée',
|
||||||
|
'description' => "Une facture a été créée à partir du devis #{$quote->id}.",
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to log invoice creation activity: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return $invoice;
|
return $invoice;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -93,6 +112,22 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
|
|||||||
// Record initial status history
|
// Record initial status history
|
||||||
$this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created');
|
$this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->timelineRepository->logActivity([
|
||||||
|
'client_id' => $invoice->client_id,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'actor_user_id' => auth()->id(),
|
||||||
|
'event_type' => 'invoice_created',
|
||||||
|
'entity_type' => 'invoice',
|
||||||
|
'entity_id' => $invoice->id,
|
||||||
|
'title' => 'Nouvelle facture créée',
|
||||||
|
'description' => "La facture #{$invoice->id} a été créée.",
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to log manual invoice creation activity: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return $invoice;
|
return $invoice;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error creating invoice with lines: ' . $e->getMessage(), [
|
Log::error('Error creating invoice with lines: ' . $e->getMessage(), [
|
||||||
|
|||||||
@ -8,12 +8,15 @@ use App\Models\Quote;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||||
|
|
||||||
class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Quote $model,
|
Quote $model,
|
||||||
protected QuoteLineRepositoryInterface $quoteLineRepository,
|
protected QuoteLineRepositoryInterface $quoteLineRepository,
|
||||||
protected InvoiceRepositoryInterface $invoiceRepository
|
protected InvoiceRepositoryInterface $invoiceRepository,
|
||||||
|
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||||
) {
|
) {
|
||||||
parent::__construct($model);
|
parent::__construct($model);
|
||||||
}
|
}
|
||||||
@ -42,6 +45,22 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
// Record initial status history
|
// Record initial status history
|
||||||
$this->recordHistory($quote->id, null, $quote->status, 'Quote created');
|
$this->recordHistory($quote->id, null, $quote->status, 'Quote created');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->timelineRepository->logActivity([
|
||||||
|
'client_id' => $quote->client_id,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'actor_user_id' => auth()->id(),
|
||||||
|
'event_type' => 'quote_created',
|
||||||
|
'entity_type' => 'quote',
|
||||||
|
'entity_id' => $quote->id,
|
||||||
|
'title' => 'Nouveau devis créé',
|
||||||
|
'description' => "Le devis #{$quote->id} a été créé.",
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to log quote creation activity: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return $quote;
|
return $quote;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
|
Log::error('Error creating quote with lines: ' . $e->getMessage(), [
|
||||||
|
|||||||
31
thanasoft-back/database/factories/ClientFactory.php
Normal file
31
thanasoft-back/database/factories/ClientFactory.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class ClientFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Client::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->company(),
|
||||||
|
'vat_number' => 'FR' . $this->faker->numerify('###########'),
|
||||||
|
'siret' => $this->faker->numerify('##############'),
|
||||||
|
'email' => $this->faker->unique()->companyEmail(),
|
||||||
|
'phone' => $this->faker->phoneNumber(),
|
||||||
|
'billing_address_line1' => $this->faker->streetAddress(),
|
||||||
|
'billing_city' => $this->faker->city(),
|
||||||
|
'billing_postal_code' => $this->faker->postcode(),
|
||||||
|
'billing_country_code' => 'FR',
|
||||||
|
// Assumes categories 1-5 exist from migration
|
||||||
|
'client_category_id' => $this->faker->numberBetween(1, 5),
|
||||||
|
'is_active' => true,
|
||||||
|
'is_parent' => false,
|
||||||
|
'notes' => $this->faker->optional()->sentence(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ return new class extends Migration
|
|||||||
Schema::create('client_categories', function (Blueprint $table) {
|
Schema::create('client_categories', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name', 255);
|
$table->string('name', 255);
|
||||||
$table->string('slug', 255)->unique();
|
$table->string('slug', 191)->unique();
|
||||||
$table->text('description')->nullable();
|
$table->text('description')->nullable();
|
||||||
$table->boolean('is_active')->default(true);
|
$table->boolean('is_active')->default(true);
|
||||||
$table->integer('sort_order')->default(0);
|
$table->integer('sort_order')->default(0);
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
<?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::create('client_activity_timeline', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('client_id');
|
||||||
|
$table->enum('actor_type', ['user', 'system', 'api'])->default('user');
|
||||||
|
$table->unsignedBigInteger('actor_user_id')->nullable();
|
||||||
|
|
||||||
|
$table->enum('event_type', [
|
||||||
|
'quote_created',
|
||||||
|
'quote_sent',
|
||||||
|
'quote_status_changed',
|
||||||
|
'invoice_created',
|
||||||
|
'invoice_sent',
|
||||||
|
'invoice_paid',
|
||||||
|
'intervention_created',
|
||||||
|
'intervention_scheduled',
|
||||||
|
'intervention_completed',
|
||||||
|
'call',
|
||||||
|
'email_sent',
|
||||||
|
'email_received',
|
||||||
|
'attachment_sent',
|
||||||
|
'attachment_received',
|
||||||
|
'client_created',
|
||||||
|
'client_info_updated',
|
||||||
|
'note'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$table->enum('entity_type', [
|
||||||
|
'quote',
|
||||||
|
'invoice',
|
||||||
|
'intervention',
|
||||||
|
'email',
|
||||||
|
'file',
|
||||||
|
'client',
|
||||||
|
'payment'
|
||||||
|
])->nullable();
|
||||||
|
|
||||||
|
$table->unsignedBigInteger('entity_id')->nullable();
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->dateTime('created_at')->useCurrent();
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
$table->index(['entity_type', 'entity_id'], 'idx_cat_entity');
|
||||||
|
$table->index(['event_type'], 'idx_cat_event');
|
||||||
|
|
||||||
|
// Foreign Keys
|
||||||
|
$table->foreign('client_id', 'fk_cat_client')
|
||||||
|
->references('id')->on('clients')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->foreign('actor_user_id', 'fk_cat_user')
|
||||||
|
->references('id')->on('users')
|
||||||
|
->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('client_activity_timeline');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -16,8 +16,9 @@ class DatabaseSeeder extends Seeder
|
|||||||
// User::factory(10)->create();
|
// User::factory(10)->create();
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Admin User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'admin@admin.com',
|
||||||
|
'password' => 'password',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->call(ProductCategorySeeder::class);
|
$this->call(ProductCategorySeeder::class);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ use App\Http\Controllers\Api\InterventionController;
|
|||||||
use App\Http\Controllers\Api\FileController;
|
use App\Http\Controllers\Api\FileController;
|
||||||
use App\Http\Controllers\Api\FileAttachmentController;
|
use App\Http\Controllers\Api\FileAttachmentController;
|
||||||
use App\Http\Controllers\Api\QuoteController;
|
use App\Http\Controllers\Api\QuoteController;
|
||||||
|
use App\Http\Controllers\Api\ClientActivityTimelineController;
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -62,6 +63,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::post('clients/{id}/children/{childId}', [ClientController::class, 'addChild']);
|
Route::post('clients/{id}/children/{childId}', [ClientController::class, 'addChild']);
|
||||||
Route::delete('clients/{id}/children/{childId}', [ClientController::class, 'removeChild']);
|
Route::delete('clients/{id}/children/{childId}', [ClientController::class, 'removeChild']);
|
||||||
Route::patch('clients/{id}/status', [ClientController::class, 'changeStatus']);
|
Route::patch('clients/{id}/status', [ClientController::class, 'changeStatus']);
|
||||||
|
Route::get('clients/{client}/timeline', [ClientActivityTimelineController::class, 'index']);
|
||||||
|
|
||||||
// Contact management
|
// Contact management
|
||||||
Route::apiResource('contacts', ContactController::class);
|
Route::apiResource('contacts', ContactController::class);
|
||||||
|
|||||||
@ -3,5 +3,9 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/{any}', function () {
|
Route::get('/{any}', function () {
|
||||||
return file_get_contents(public_path('build/index.html'));
|
$path = public_path('build/index.html');
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return response('Frontend build not found. Please run "npm run build" in the thanasoft-front directory.', 404);
|
||||||
|
}
|
||||||
|
return file_get_contents($path);
|
||||||
})->where('any', '.*');
|
})->where('any', '.*');
|
||||||
|
|||||||
@ -13,8 +13,12 @@
|
|||||||
<client-table
|
<client-table
|
||||||
:data="clientData"
|
:data="clientData"
|
||||||
:loading="loadingData"
|
:loading="loadingData"
|
||||||
|
:pagination="pagination"
|
||||||
@view="goToDetails"
|
@view="goToDetails"
|
||||||
@delete="deleteClient"
|
@delete="deleteClient"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@per-page-change="onPerPageChange"
|
||||||
|
@search-change="onSearch"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</client-template>
|
</client-template>
|
||||||
@ -27,12 +31,19 @@ import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
|||||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const clientStore = useClientStore();
|
||||||
|
// Use storeToRefs to keep reactivity for pagination if it was passed as a prop,
|
||||||
|
// but since we are using the store directly for actions, we can also extract it here if needed.
|
||||||
|
// However, the common pattern is that the parent view passes the data.
|
||||||
|
// Let's check where clientData comes from. It comes from props.
|
||||||
|
|
||||||
const emit = defineEmits(["pushDetails"]);
|
const emit = defineEmits(["pushDetails"]);
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
clientData: {
|
clientData: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
@ -41,6 +52,11 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
// We need to accept pagination as a prop if it is passed from the view
|
||||||
|
pagination: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const goToClient = () => {
|
const goToClient = () => {
|
||||||
@ -56,4 +72,16 @@ const goToDetails = (client) => {
|
|||||||
const deleteClient = (client) => {
|
const deleteClient = (client) => {
|
||||||
emit("deleteClient", client);
|
emit("deleteClient", client);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPageChange = (page) => {
|
||||||
|
clientStore.fetchClients({ page: page, per_page: props.pagination.per_page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPerPageChange = (perPage) => {
|
||||||
|
clientStore.fetchClients({ page: 1, per_page: perPage });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query) => {
|
||||||
|
clientStore.fetchClients({ page: 1, per_page: props.pagination.per_page, search: query });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'activity'">
|
<div v-show="activeTab === 'activity'">
|
||||||
<ClientActivityTab />
|
<ClientActivityTab :client-id="clientId" :active-tab="activeTab" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contacts Tab -->
|
<!-- Contacts Tab -->
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ListeLieuxTemplate from "@/components/templates/CRM/lieux/ListeLieuxTemplate.vue";
|
import ListeLieuxTemplate from "@/components/templates/CRM/lieux/ListeLieuxTemplate.vue";
|
||||||
import LocationTable from "@/components/molecules/location/LocationTable.vue";
|
import LocationTable from "@/components/molecules/Location/LocationTable.vue";
|
||||||
import addButton from "@/components/molecules/new-button/addButton.vue";
|
import addButton from "@/components/molecules/new-button/addButton.vue";
|
||||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||||
|
|||||||
@ -59,23 +59,26 @@
|
|||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<div class="dropdown d-inline-block me-2">
|
<div class="position-relative d-inline-block me-2">
|
||||||
<soft-button
|
<soft-button
|
||||||
id="statusDropdown"
|
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
class="dropdown-toggle"
|
@click="dropdownOpen = !dropdownOpen"
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
>
|
||||||
{{ getStatusLabel(invoice.status) }}
|
{{ getStatusLabel(invoice.status) }}
|
||||||
|
<i class="fas fa-chevron-down ms-2"></i>
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<ul class="dropdown-menu" aria-labelledby="statusDropdown">
|
<ul
|
||||||
|
v-if="dropdownOpen"
|
||||||
|
class="dropdown-menu show position-absolute"
|
||||||
|
style="top: 100%; left: 0; z-index: 1000;"
|
||||||
|
>
|
||||||
<li v-for="status in availableStatuses" :key="status">
|
<li v-for="status in availableStatuses" :key="status">
|
||||||
<a
|
<a
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
:class="{ active: status === invoice.status }"
|
:class="{ active: status === invoice.status }"
|
||||||
href="javascript:;"
|
href="javascript:;"
|
||||||
@click="changeStatus(status)"
|
@click="changeStatus(status); dropdownOpen = false;"
|
||||||
>
|
>
|
||||||
{{ getStatusLabel(status) }}
|
{{ getStatusLabel(status) }}
|
||||||
</a>
|
</a>
|
||||||
@ -115,6 +118,7 @@ const notificationStore = useNotificationStore();
|
|||||||
const invoice = ref(null);
|
const invoice = ref(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
|
const dropdownOpen = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|||||||
@ -42,33 +42,32 @@
|
|||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<div class="dropdown d-inline-block me-2">
|
<div class="position-relative d-inline-block me-2">
|
||||||
<soft-button
|
<soft-button
|
||||||
id="dropdownMenuButton"
|
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
class="dropdown-toggle"
|
@click="dropdownOpen = !dropdownOpen"
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
>
|
||||||
{{ getStatusLabel(quote.status) }}
|
{{ getStatusLabel(quote.status) }}
|
||||||
|
<i class="fas fa-chevron-down ms-2"></i>
|
||||||
</soft-button>
|
</soft-button>
|
||||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
<ul
|
||||||
|
v-if="dropdownOpen"
|
||||||
|
class="dropdown-menu show position-absolute"
|
||||||
|
style="top: 100%; left: 0; z-index: 1000;"
|
||||||
|
>
|
||||||
<li v-for="status in availableStatuses" :key="status">
|
<li v-for="status in availableStatuses" :key="status">
|
||||||
<a
|
<a
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
:class="{ active: status === quote.status }"
|
:class="{ active: status === quote.status }"
|
||||||
href="javascript:;"
|
href="javascript:;"
|
||||||
@click="changeStatus(status)"
|
@click="changeStatus(status); dropdownOpen = false;"
|
||||||
>
|
>
|
||||||
{{ getStatusLabel(status) }}
|
{{ getStatusLabel(status) }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<soft-button color="info" variant="outline">
|
|
||||||
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
|
||||||
</soft-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</quote-detail-template>
|
</quote-detail-template>
|
||||||
@ -100,6 +99,7 @@ const notificationStore = useNotificationStore();
|
|||||||
const quote = ref(null);
|
const quote = ref(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
|
const dropdownOpen = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn mb-0"
|
class="btn mb-0"
|
||||||
:class="getClasses(variant, color, size, fullWidth, active)"
|
:class="getClasses(variant, color, size, fullWidth, active)"
|
||||||
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
@ -10,6 +11,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: "SoftButton",
|
name: "SoftButton",
|
||||||
|
inheritAttrs: false,
|
||||||
props: {
|
props: {
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -1,5 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
|
<!-- Top Controls (Search & Per Page) -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<select
|
||||||
|
class="form-select form-select-sm me-2"
|
||||||
|
style="width: 80px"
|
||||||
|
:value="pagination.per_page"
|
||||||
|
@change="onPerPageChange"
|
||||||
|
>
|
||||||
|
<option :value="5">5</option>
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="15">15</option>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-secondary text-xs font-weight-bold">éléments par page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text text-body"><i class="fas fa-search" aria-hidden="true"></i></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
@input="onSearch"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
@ -173,6 +204,7 @@
|
|||||||
title="View Client"
|
title="View Client"
|
||||||
:data-client-id="client.id"
|
:data-client-id="client.id"
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||||
|
@click="emit('view', client.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||||
</soft-button>
|
</soft-button>
|
||||||
@ -184,6 +216,7 @@
|
|||||||
title="Delete Client"
|
title="Delete Client"
|
||||||
:data-client-id="client.id"
|
:data-client-id="client.id"
|
||||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||||
|
@click="emit('delete', client.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
</soft-button>
|
</soft-button>
|
||||||
@ -194,6 +227,39 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Footer -->
|
||||||
|
<div v-if="!loading && data.length > 0" class="d-flex justify-content-between align-items-center mt-3 px-3">
|
||||||
|
<div class="text-xs text-secondary font-weight-bold">
|
||||||
|
Affichage de {{ pagination.from }} à {{ pagination.to }} sur {{ pagination.total }} clients
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="pagination pagination-sm pagination-success mb-0">
|
||||||
|
<li class="page-item" :class="{ disabled: pagination.current_page === 1 }">
|
||||||
|
<a class="page-link" href="#" aria-label="Previous" @click.prevent="changePage(pagination.current_page - 1)">
|
||||||
|
<span aria-hidden="true"><i class="fa fa-angle-left" aria-hidden="true"></i></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li
|
||||||
|
v-for="page in displayedPages"
|
||||||
|
:key="page"
|
||||||
|
class="page-item"
|
||||||
|
:class="{ active: pagination.current_page === page }"
|
||||||
|
>
|
||||||
|
<a class="page-link" href="#" @click.prevent="changePage(page)">{{ page }}</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="page-item" :class="{ disabled: pagination.current_page === pagination.last_page }">
|
||||||
|
<a class="page-link" href="#" aria-label="Next" @click.prevent="changePage(pagination.current_page + 1)">
|
||||||
|
<span aria-hidden="true"><i class="fa fa-angle-right" aria-hidden="true"></i></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="!loading && data.length === 0" class="empty-state">
|
<div v-if="!loading && data.length === 0" class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
@ -209,15 +275,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, onUnmounted } from "vue";
|
import { computed } from "vue";
|
||||||
import { DataTable } from "simple-datatables";
|
|
||||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||||
import addButton from "../../new-button/addButton.vue";
|
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
const emit = defineEmits(["view"]);
|
const emit = defineEmits(["view", "delete", "page-change", "per-page-change", "search-change"]);
|
||||||
|
|
||||||
// Sample avatar images
|
// Sample avatar images
|
||||||
import img1 from "@/assets/img/team-2.jpg";
|
import img1 from "@/assets/img/team-2.jpg";
|
||||||
@ -229,10 +294,6 @@ import img6 from "@/assets/img/ivana-squares.jpg";
|
|||||||
|
|
||||||
const avatarImages = [img1, img2, img3, img4, img5, img6];
|
const avatarImages = [img1, img2, img3, img4, img5, img6];
|
||||||
|
|
||||||
// Reactive data
|
|
||||||
const contacts = ref([]);
|
|
||||||
const dataTableInstance = ref(null);
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -246,9 +307,61 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 5,
|
default: 5,
|
||||||
},
|
},
|
||||||
|
pagination: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate displayed page numbers
|
||||||
|
const displayedPages = computed(() => {
|
||||||
|
const total = props.pagination.last_page;
|
||||||
|
const current = props.pagination.current_page;
|
||||||
|
const delta = 2;
|
||||||
|
const range = [];
|
||||||
|
|
||||||
|
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) {
|
||||||
|
range.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current - delta > 2) {
|
||||||
|
range.unshift("...");
|
||||||
|
}
|
||||||
|
if (current + delta < total - 1) {
|
||||||
|
range.push("...");
|
||||||
|
}
|
||||||
|
|
||||||
|
range.unshift(1);
|
||||||
|
if (total > 1) {
|
||||||
|
range.push(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return range.filter((val, index, self) => val !== "..." || (val === "..." && self[index - 1] !== "..."));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
const changePage = (page) => {
|
||||||
|
if (page !== "..." && page >= 1 && page <= props.pagination.last_page) {
|
||||||
|
emit("page-change", page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPerPageChange = (event) => {
|
||||||
|
const newPerPage = parseInt(event.target.value);
|
||||||
|
emit("per-page-change", newPerPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = debounce((event) => {
|
||||||
|
emit("search-change", event.target.value);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
const getRandomAvatar = () => {
|
const getRandomAvatar = () => {
|
||||||
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
||||||
return avatarImages[randomIndex];
|
return avatarImages[randomIndex];
|
||||||
@ -277,71 +390,6 @@ const getCategoryIcon = (type) => {
|
|||||||
};
|
};
|
||||||
return icons[type] || "fas fa-circle";
|
return icons[type] || "fas fa-circle";
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeDataTable = () => {
|
|
||||||
// Destroy existing instance if it exists
|
|
||||||
if (dataTableInstance.value) {
|
|
||||||
dataTableInstance.value.destroy();
|
|
||||||
dataTableInstance.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataTableEl = document.getElementById("contact-list");
|
|
||||||
if (dataTableEl) {
|
|
||||||
dataTableInstance.value = new DataTable(dataTableEl, {
|
|
||||||
searchable: true,
|
|
||||||
fixedHeight: true,
|
|
||||||
perPage: 10,
|
|
||||||
perPageSelect: [5, 10, 15, 20],
|
|
||||||
});
|
|
||||||
|
|
||||||
dataTableEl.addEventListener("click", handleTableClick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTableClick = (event) => {
|
|
||||||
const button = event.target.closest("button");
|
|
||||||
if (!button) return;
|
|
||||||
const clientId = button.getAttribute("data-client-id");
|
|
||||||
if (button.title === "Delete Client" || button.querySelector(".fa-trash")) {
|
|
||||||
emit("delete", clientId);
|
|
||||||
} else if (
|
|
||||||
button.title === "View Client" ||
|
|
||||||
button.textContent?.includes("Test")
|
|
||||||
) {
|
|
||||||
emit("view", clientId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
const dataTableEl = document.getElementById("contact-list");
|
|
||||||
if (dataTableEl) {
|
|
||||||
dataTableEl.removeEventListener("click", handleTableClick);
|
|
||||||
}
|
|
||||||
if (dataTableInstance.value) {
|
|
||||||
dataTableInstance.value.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize data
|
|
||||||
onMounted(() => {
|
|
||||||
if (!props.loading && props.data.length > 0) {
|
|
||||||
initializeDataTable();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -492,3 +540,152 @@ onMounted(() => {
|
|||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-icon.small {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div class="card activity-list-card">
|
<div class="card activity-list-card">
|
||||||
<div class="card-header pb-0">
|
<div class="card-header pb-0">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<h6 class="mb-0">Activités du client</h6>
|
<h6 class="mb-0">Activités récentes</h6>
|
||||||
<div class="ms-auto">
|
<div class="ms-auto">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<button
|
<button
|
||||||
@ -16,32 +16,32 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
:class="{ active: activeFilter === 'calls' }"
|
:class="{ active: activeFilter === 'call' }"
|
||||||
@click="activeFilter = 'calls'"
|
@click="activeFilter = 'call'"
|
||||||
>
|
>
|
||||||
Appels
|
Appels
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
:class="{ active: activeFilter === 'emails' }"
|
:class="{ active: activeFilter === 'email' }"
|
||||||
@click="activeFilter = 'emails'"
|
@click="activeFilter = 'email'"
|
||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
:class="{ active: activeFilter === 'invoices' }"
|
:class="{ active: activeFilter === 'invoice' }"
|
||||||
@click="activeFilter = 'invoices'"
|
@click="activeFilter = 'invoice'"
|
||||||
>
|
>
|
||||||
Factures
|
Factures
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
:class="{ active: activeFilter === 'files' }"
|
:class="{ active: activeFilter === 'file' }"
|
||||||
@click="activeFilter = 'files'"
|
@click="activeFilter = 'file'"
|
||||||
>
|
>
|
||||||
Fichiers
|
Fichiers
|
||||||
</button>
|
</button>
|
||||||
@ -50,264 +50,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body activity-list-body p-0">
|
<div class="card-body activity-list-body p-0">
|
||||||
<!-- Activity Timeline -->
|
<!-- Loading State -->
|
||||||
<div class="timeline-container">
|
<div v-if="loading" class="text-center py-5">
|
||||||
<!-- Call Activity -->
|
<div class="spinner-border text-primary" role="status">
|
||||||
<div class="timeline-item" data-type="call">
|
<span class="visually-hidden">Chargement...</span>
|
||||||
<div class="timeline-badge bg-info">
|
|
||||||
<i class="fas fa-phone"></i>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1">Appel téléphonique</h6>
|
|
||||||
<p class="text-sm text-muted mb-1">
|
|
||||||
<i class="fas fa-clock me-1"></i>
|
|
||||||
Il y a 2 heures • Durée: 15min
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-0">
|
|
||||||
Discussion concernant le nouveau projet. Le client est
|
|
||||||
satisfait de la proposition.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-link text-secondary p-0"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-edit me-2"></i>Modifier</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item text-danger" href="#"
|
|
||||||
><i class="fas fa-trash me-2"></i>Supprimer</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email Activity -->
|
|
||||||
<div class="timeline-item" data-type="email">
|
|
||||||
<div class="timeline-badge bg-success">
|
|
||||||
<i class="fas fa-envelope"></i>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1">Email envoyé</h6>
|
|
||||||
<p class="text-sm text-muted mb-1">
|
|
||||||
<i class="fas fa-clock me-1"></i>
|
|
||||||
Il y a 4 heures • Sujet: Devis projet X
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-0">
|
|
||||||
Envoi du devis signé et de la documentation technique.
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="badge bg-light text-dark me-2">
|
|
||||||
<i class="fas fa-paperclip me-1"></i>devis.pdf
|
|
||||||
</span>
|
|
||||||
<span class="badge bg-light text-dark">
|
|
||||||
<i class="fas fa-paperclip me-1"></i>specifications.docx
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-link text-secondary p-0"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-reply me-2"></i>Répondre</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-edit me-2"></i>Modifier</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice Activity -->
|
|
||||||
<div class="timeline-item" data-type="invoice">
|
|
||||||
<div class="timeline-badge bg-warning">
|
|
||||||
<i class="fas fa-file-invoice-dollar"></i>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1">Nouvelle facture créée</h6>
|
|
||||||
<p class="text-sm text-muted mb-1">
|
|
||||||
<i class="fas fa-clock me-1"></i>
|
|
||||||
Il y a 1 jour • Facture #FACT-2024-001
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-0">
|
|
||||||
Montant: <strong>1 250,00 €</strong> • Échéance: 30/01/2024
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="badge bg-success me-2">Payée</span>
|
|
||||||
<span class="badge bg-light text-dark">
|
|
||||||
<i class="fas fa-download me-1"></i>Télécharger
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-link text-secondary p-0"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-edit me-2"></i>Modifier</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-envelope me-2"></i>Envoyer</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item text-danger" href="#"
|
|
||||||
><i class="fas fa-trash me-2"></i>Supprimer</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload Activity -->
|
|
||||||
<div class="timeline-item" data-type="file">
|
|
||||||
<div class="timeline-badge bg-primary">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1">Fichier ajouté</h6>
|
|
||||||
<p class="text-sm text-muted mb-1">
|
|
||||||
<i class="fas fa-clock me-1"></i>
|
|
||||||
Il y a 2 jours • Contrat signé
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-0">
|
|
||||||
Le client a uploadé le contrat signé pour le projet en cours.
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="badge bg-light text-dark me-2">
|
|
||||||
<i class="fas fa-file-pdf me-1 text-danger"></i
|
|
||||||
>contrat_signé.pdf
|
|
||||||
</span>
|
|
||||||
<span class="badge bg-secondary">2.4 MB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-link text-secondary p-0"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-download me-2"></i>Télécharger</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-share me-2"></i>Partager</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item text-danger" href="#"
|
|
||||||
><i class="fas fa-trash me-2"></i>Supprimer</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meeting Activity -->
|
|
||||||
<div class="timeline-item" data-type="meeting">
|
|
||||||
<div class="timeline-badge bg-purple">
|
|
||||||
<i class="fas fa-video"></i>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1">Réunion planifiée</h6>
|
|
||||||
<p class="text-sm text-muted mb-1">
|
|
||||||
<i class="fas fa-clock me-1"></i>
|
|
||||||
Il y a 3 jours • 15 Janvier 2024, 14:00
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-0">
|
|
||||||
Révision du projet avec l'équipe technique.
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="badge bg-info me-2">Teams</span>
|
|
||||||
<span class="badge bg-light text-dark">
|
|
||||||
<i class="fas fa-calendar me-1"></i>Ajouter au calendrier
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-link text-secondary p-0"
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
>
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="#"
|
|
||||||
><i class="fas fa-edit me-2"></i>Modifier</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item text-danger" href="#"
|
|
||||||
><i class="fas fa-trash me-2"></i>Annuler</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary">Récupération des activités...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Activity Timeline -->
|
||||||
<div v-if="false" class="text-center py-5">
|
<div v-else class="p-3">
|
||||||
<i class="fas fa-stream fa-3x text-secondary opacity-5 mb-3"></i>
|
<timeline-list title="Activités récentes">
|
||||||
<p class="text-sm text-secondary">Aucune activité pour ce client</p>
|
<timeline-item
|
||||||
<button class="btn btn-primary btn-sm mt-2">
|
v-for="(activity, index) in filteredActivities"
|
||||||
<i class="fas fa-plus me-1"></i>Ajouter une activité
|
:key="index"
|
||||||
</button>
|
:color="activity.color"
|
||||||
|
:icon="activity.icon"
|
||||||
|
:title="activity.title"
|
||||||
|
:date-time="activity.time_ago"
|
||||||
|
:description="activity.description"
|
||||||
|
:badges="activity.metadata?.badges || []"
|
||||||
|
/>
|
||||||
|
</timeline-list>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="filteredActivities.length === 0" class="text-center py-5">
|
||||||
|
<i class="fas fa-stream fa-3x text-secondary opacity-5 mb-3"></i>
|
||||||
|
<p class="text-sm text-secondary">Aucune activité pour ce filtre</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -335,9 +105,76 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed, onMounted, watch, defineProps } from "vue";
|
||||||
|
import TimelineList from "@/views/pages/projects/components/TimelineList.vue";
|
||||||
|
import TimelineItem from "@/views/pages/projects/components/TimelineItem.vue";
|
||||||
|
import { useClientTimelineStore } from "@/stores/clientTimelineStore";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
clientId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
activeTab: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useClientTimelineStore();
|
||||||
const activeFilter = ref("all");
|
const activeFilter = ref("all");
|
||||||
|
|
||||||
|
const activities = computed(() => store.activities);
|
||||||
|
const loading = computed(() => store.loading);
|
||||||
|
|
||||||
|
// Fetch activities when component is mounted or clientId changes
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
if (props.clientId) {
|
||||||
|
await store.fetchTimeline(Number(props.clientId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.activeTab === "activity") {
|
||||||
|
fetchActivities();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.clientId,
|
||||||
|
() => {
|
||||||
|
if (props.activeTab === "activity") {
|
||||||
|
fetchActivities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeTab,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal === "activity") {
|
||||||
|
fetchActivities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const filteredActivities = computed(() => {
|
||||||
|
if (activeFilter.value === "all") {
|
||||||
|
return activities.value;
|
||||||
|
}
|
||||||
|
// Filter locally based on event_type mapping to filter categories
|
||||||
|
return activities.value.filter((a) => {
|
||||||
|
const type = a.event_type;
|
||||||
|
switch (activeFilter.value) {
|
||||||
|
case 'call': return type === 'call';
|
||||||
|
case 'email': return ['email_sent', 'email_received'].includes(type);
|
||||||
|
case 'invoice': return ['invoice_created', 'invoice_sent', 'invoice_paid'].includes(type);
|
||||||
|
case 'file': return ['file_uploaded', 'attachment_sent', 'attachment_received'].includes(type);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -350,59 +187,6 @@ const activeFilter = ref("all");
|
|||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Timeline Styles */
|
|
||||||
.timeline-container {
|
|
||||||
position: relative;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 2rem;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-badge {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
margin-right: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-content {
|
|
||||||
flex: 1;
|
|
||||||
background: white;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-content h6 {
|
|
||||||
color: #344767;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-purple {
|
|
||||||
background-color: #6f42c1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter buttons active state */
|
/* Filter buttons active state */
|
||||||
.btn-group .btn.active {
|
.btn-group .btn.active {
|
||||||
background-color: #cb0c9f;
|
background-color: #cb0c9f;
|
||||||
@ -410,17 +194,6 @@ const activeFilter = ref("all");
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badge styles */
|
|
||||||
.badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown styles */
|
|
||||||
.dropdown-menu {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick actions */
|
/* Quick actions */
|
||||||
.card-footer {
|
.card-footer {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
@ -434,16 +207,6 @@ const activeFilter = ref("all");
|
|||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.timeline-container::before {
|
|
||||||
left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-badge {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, ref } from "vue";
|
import { defineProps, defineEmits, ref } from "vue";
|
||||||
import LocationModal from "../location/LocationModal.vue";
|
import LocationModal from "../Location/LocationModal.vue";
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
|
|||||||
@ -10,6 +10,7 @@ Coded by www.creative-tim.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
|
import "bootstrap/dist/js/bootstrap.bundle.min.js";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
|||||||
51
thanasoft-front/src/services/client-timeline.ts
Normal file
51
thanasoft-front/src/services/client-timeline.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
export interface TimelineActivity {
|
||||||
|
id: number;
|
||||||
|
client_id: number;
|
||||||
|
actor_type: string;
|
||||||
|
actor_name: string;
|
||||||
|
event_type: string;
|
||||||
|
entity_type: string | null;
|
||||||
|
entity_id: number | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
metadata: any;
|
||||||
|
created_at: string;
|
||||||
|
time_ago: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResponse {
|
||||||
|
data: TimelineActivity[];
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientTimelineService = {
|
||||||
|
/**
|
||||||
|
* Get timeline activities for a client
|
||||||
|
*/
|
||||||
|
async getTimeline(
|
||||||
|
clientId: number,
|
||||||
|
params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
}
|
||||||
|
): Promise<TimelineResponse> {
|
||||||
|
const response = await request<TimelineResponse>({
|
||||||
|
url: `/api/clients/${clientId}/timeline`,
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientTimelineService;
|
||||||
@ -74,11 +74,12 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
|
|
||||||
const setPagination = (meta: any) => {
|
const setPagination = (meta: any) => {
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
const getValue = (val: any) => (Array.isArray(val) ? val[0] : val);
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
current_page: meta.current_page || 1,
|
current_page: Number(getValue(meta.current_page)) || 1,
|
||||||
last_page: meta.last_page || 1,
|
last_page: Number(getValue(meta.last_page)) || 1,
|
||||||
per_page: meta.per_page || 10,
|
per_page: Number(getValue(meta.per_page)) || 10,
|
||||||
total: meta.total || 0,
|
total: Number(getValue(meta.total)) || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -98,6 +99,7 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ClientService.getAllClients(params);
|
const response = await ClientService.getAllClients(params);
|
||||||
|
console.log('API Response:', response);
|
||||||
setClients(response.data);
|
setClients(response.data);
|
||||||
if (response.meta) {
|
if (response.meta) {
|
||||||
setPagination(response.meta);
|
setPagination(response.meta);
|
||||||
|
|||||||
65
thanasoft-front/src/stores/clientTimelineStore.ts
Normal file
65
thanasoft-front/src/stores/clientTimelineStore.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import ClientTimelineService, {
|
||||||
|
TimelineActivity,
|
||||||
|
} from "@/services/client-timeline";
|
||||||
|
|
||||||
|
export const useClientTimelineStore = defineStore("clientTimeline", () => {
|
||||||
|
const activities = ref<TimelineActivity[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPagination = (meta: any) => {
|
||||||
|
if (meta) {
|
||||||
|
const getValue = (val: any) => (Array.isArray(val) ? val[0] : val);
|
||||||
|
pagination.value = {
|
||||||
|
current_page: Number(getValue(meta.current_page)) || 1,
|
||||||
|
last_page: Number(getValue(meta.last_page)) || 1,
|
||||||
|
per_page: Number(getValue(meta.per_page)) || 10,
|
||||||
|
total: Number(getValue(meta.total)) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTimeline = async (clientId: number, params: any = {}) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
// Merge basic pagination params if not provided
|
||||||
|
const queryParams = {
|
||||||
|
page: pagination.value.current_page,
|
||||||
|
per_page: pagination.value.per_page,
|
||||||
|
...params
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await ClientTimelineService.getTimeline(clientId, queryParams);
|
||||||
|
|
||||||
|
activities.value = response.data;
|
||||||
|
if (response.meta) {
|
||||||
|
setPagination(response.meta);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error fetching timeline:", err);
|
||||||
|
error.value = err.message || "Failed to fetch timeline";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activities,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pagination,
|
||||||
|
fetchTimeline,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@
|
|||||||
<client-presentation
|
<client-presentation
|
||||||
:client-data="clientStore.clients"
|
:client-data="clientStore.clients"
|
||||||
:loading-data="clientStore.loading"
|
:loading-data="clientStore.loading"
|
||||||
|
:pagination="clientStore.getPagination"
|
||||||
@push-details="goDetails"
|
@push-details="goDetails"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card" :class="darkMode ? 'bg-gradient-dark' : ''">
|
<div class="card" :class="darkMode ? 'bg-gradient-dark' : ''">
|
||||||
<div class="pb-0 card-header" :class="darkMode ? 'bg-transparent' : ''">
|
<div class="pb-0 card-header" :class="darkMode ? 'bg-transparent' : ''">
|
||||||
<h6 :class="darkMode ? 'text-white' : ''">{{ title }}</h6>
|
<!-- <h6 :class="darkMode ? 'text-white' : ''">{{ title }}</h6> -->
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<p class="text-sm" v-html="description"></p>
|
<p class="text-sm" v-html="description"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user