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
|
||||
{
|
||||
try {
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
$filters = [
|
||||
'search' => $request->get('search'),
|
||||
'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\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'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;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@ -13,7 +14,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Repository interface to implementation bindings
|
||||
$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) {
|
||||
@ -67,14 +71,30 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
$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\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\ClientActivityTimelineRepositoryInterface::class, \App\Repositories\ClientActivityTimelineRepository::class);
|
||||
}
|
||||
|
||||
|
||||
@ -84,6 +104,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
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\Database\Eloquent\Collection;
|
||||
|
||||
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||
use Illuminate\Support\Facades\Log as LaravelLog;
|
||||
|
||||
class ClientRepository extends BaseRepository implements ClientRepositoryInterface
|
||||
{
|
||||
public function __construct(Client $model)
|
||||
{
|
||||
public function __construct(
|
||||
Client $model,
|
||||
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||
) {
|
||||
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
|
||||
|
||||
@ -6,8 +6,14 @@ use App\Models\Intervention;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InterventionRepository implements InterventionRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all interventions with optional filtering and pagination
|
||||
*
|
||||
@ -88,7 +94,25 @@ class InterventionRepository implements InterventionRepositoryInterface
|
||||
public function create(array $data): Intervention
|
||||
{
|
||||
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\Log;
|
||||
|
||||
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||
|
||||
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
Invoice $model,
|
||||
protected InvoiceLineRepositoryInterface $invoiceLineRepository
|
||||
protected InvoiceLineRepositoryInterface $invoiceLineRepository,
|
||||
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||
) {
|
||||
parent::__construct($model);
|
||||
}
|
||||
@ -64,6 +67,22 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
|
||||
// Record history
|
||||
$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;
|
||||
});
|
||||
}
|
||||
@ -93,6 +112,22 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
|
||||
// Record initial status history
|
||||
$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;
|
||||
} catch (\Exception $e) {
|
||||
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\Log;
|
||||
|
||||
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||
|
||||
class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
Quote $model,
|
||||
protected QuoteLineRepositoryInterface $quoteLineRepository,
|
||||
protected InvoiceRepositoryInterface $invoiceRepository
|
||||
protected InvoiceRepositoryInterface $invoiceRepository,
|
||||
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
|
||||
) {
|
||||
parent::__construct($model);
|
||||
}
|
||||
@ -42,6 +45,22 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
||||
// Record initial status history
|
||||
$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;
|
||||
} catch (\Exception $e) {
|
||||
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) {
|
||||
$table->id();
|
||||
$table->string('name', 255);
|
||||
$table->string('slug', 255)->unique();
|
||||
$table->string('slug', 191)->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$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()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@admin.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$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\FileAttachmentController;
|
||||
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::delete('clients/{id}/children/{childId}', [ClientController::class, 'removeChild']);
|
||||
Route::patch('clients/{id}/status', [ClientController::class, 'changeStatus']);
|
||||
Route::get('clients/{client}/timeline', [ClientActivityTimelineController::class, 'index']);
|
||||
|
||||
// Contact management
|
||||
Route::apiResource('contacts', ContactController::class);
|
||||
|
||||
@ -3,5 +3,9 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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', '.*');
|
||||
|
||||
@ -13,8 +13,12 @@
|
||||
<client-table
|
||||
:data="clientData"
|
||||
:loading="loadingData"
|
||||
:pagination="pagination"
|
||||
@view="goToDetails"
|
||||
@delete="deleteClient"
|
||||
@page-change="onPageChange"
|
||||
@per-page-change="onPerPageChange"
|
||||
@search-change="onSearch"
|
||||
/>
|
||||
</template>
|
||||
</client-template>
|
||||
@ -27,12 +31,19 @@ import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useClientStore } from "@/stores/clientStore";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
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"]);
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
clientData: {
|
||||
type: Array,
|
||||
default: [],
|
||||
@ -41,6 +52,11 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// We need to accept pagination as a prop if it is passed from the view
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const goToClient = () => {
|
||||
@ -56,4 +72,16 @@ const goToDetails = (client) => {
|
||||
const 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>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'activity'">
|
||||
<ClientActivityTab />
|
||||
<ClientActivityTab :client-id="clientId" :active-tab="activeTab" />
|
||||
</div>
|
||||
|
||||
<!-- Contacts Tab -->
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
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 TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||
|
||||
@ -59,23 +59,26 @@
|
||||
|
||||
<template #actions>
|
||||
<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
|
||||
id="statusDropdown"
|
||||
color="secondary"
|
||||
variant="gradient"
|
||||
class="dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
{{ getStatusLabel(invoice.status) }}
|
||||
<i class="fas fa-chevron-down ms-2"></i>
|
||||
</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">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
:class="{ active: status === invoice.status }"
|
||||
href="javascript:;"
|
||||
@click="changeStatus(status)"
|
||||
@click="changeStatus(status); dropdownOpen = false;"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</a>
|
||||
@ -115,6 +118,7 @@ const notificationStore = useNotificationStore();
|
||||
const invoice = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
|
||||
@ -42,33 +42,32 @@
|
||||
|
||||
<template #actions>
|
||||
<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
|
||||
id="dropdownMenuButton"
|
||||
color="secondary"
|
||||
variant="gradient"
|
||||
class="dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
{{ getStatusLabel(quote.status) }}
|
||||
<i class="fas fa-chevron-down ms-2"></i>
|
||||
</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">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
:class="{ active: status === quote.status }"
|
||||
href="javascript:;"
|
||||
@click="changeStatus(status)"
|
||||
@click="changeStatus(status); dropdownOpen = false;"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<soft-button color="info" variant="outline">
|
||||
<i class="fas fa-file-pdf me-1"></i> Télécharger PDF
|
||||
</soft-button>
|
||||
</div>
|
||||
</template>
|
||||
</quote-detail-template>
|
||||
@ -100,6 +99,7 @@ const notificationStore = useNotificationStore();
|
||||
const quote = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
<button
|
||||
class="btn mb-0"
|
||||
:class="getClasses(variant, color, size, fullWidth, active)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
@ -10,6 +11,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: "SoftButton",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
|
||||
@ -1,5 +1,36 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-spinner">
|
||||
@ -173,6 +204,7 @@
|
||||
title="View Client"
|
||||
:data-client-id="client.id"
|
||||
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>
|
||||
</soft-button>
|
||||
@ -184,6 +216,7 @@
|
||||
title="Delete Client"
|
||||
:data-client-id="client.id"
|
||||
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>
|
||||
</soft-button>
|
||||
@ -194,6 +227,39 @@
|
||||
</table>
|
||||
</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 -->
|
||||
<div v-if="!loading && data.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
@ -209,15 +275,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted } from "vue";
|
||||
import { DataTable } from "simple-datatables";
|
||||
import { computed } from "vue";
|
||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import addButton from "../../new-button/addButton.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
|
||||
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];
|
||||
|
||||
// Reactive data
|
||||
const contacts = ref([]);
|
||||
const dataTableInstance = ref(null);
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
@ -246,9 +307,61 @@ const props = defineProps({
|
||||
type: Number,
|
||||
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
|
||||
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 randomIndex = Math.floor(Math.random() * avatarImages.length);
|
||||
return avatarImages[randomIndex];
|
||||
@ -277,71 +390,6 @@ const getCategoryIcon = (type) => {
|
||||
};
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -492,3 +540,152 @@ onMounted(() => {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
</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-header pb-0">
|
||||
<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="btn-group btn-group-sm" role="group">
|
||||
<button
|
||||
@ -16,32 +16,32 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: activeFilter === 'calls' }"
|
||||
@click="activeFilter = 'calls'"
|
||||
:class="{ active: activeFilter === 'call' }"
|
||||
@click="activeFilter = 'call'"
|
||||
>
|
||||
Appels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: activeFilter === 'emails' }"
|
||||
@click="activeFilter = 'emails'"
|
||||
:class="{ active: activeFilter === 'email' }"
|
||||
@click="activeFilter = 'email'"
|
||||
>
|
||||
Emails
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: activeFilter === 'invoices' }"
|
||||
@click="activeFilter = 'invoices'"
|
||||
:class="{ active: activeFilter === 'invoice' }"
|
||||
@click="activeFilter = 'invoice'"
|
||||
>
|
||||
Factures
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: activeFilter === 'files' }"
|
||||
@click="activeFilter = 'files'"
|
||||
:class="{ active: activeFilter === 'file' }"
|
||||
@click="activeFilter = 'file'"
|
||||
>
|
||||
Fichiers
|
||||
</button>
|
||||
@ -50,264 +50,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body activity-list-body p-0">
|
||||
<!-- Activity Timeline -->
|
||||
<div class="timeline-container">
|
||||
<!-- Call Activity -->
|
||||
<div class="timeline-item" data-type="call">
|
||||
<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>
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary">Récupération des activités...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="false" 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 client</p>
|
||||
<button class="btn btn-primary btn-sm mt-2">
|
||||
<i class="fas fa-plus me-1"></i>Ajouter une activité
|
||||
</button>
|
||||
<!-- Activity Timeline -->
|
||||
<div v-else class="p-3">
|
||||
<timeline-list title="Activités récentes">
|
||||
<timeline-item
|
||||
v-for="(activity, index) in filteredActivities"
|
||||
:key="index"
|
||||
: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>
|
||||
|
||||
@ -335,9 +105,76 @@
|
||||
</template>
|
||||
|
||||
<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 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>
|
||||
|
||||
<style scoped>
|
||||
@ -350,59 +187,6 @@ const activeFilter = ref("all");
|
||||
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 */
|
||||
.btn-group .btn.active {
|
||||
background-color: #cb0c9f;
|
||||
@ -410,17 +194,6 @@ const activeFilter = ref("all");
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.dropdown-menu {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.card-footer {
|
||||
background-color: #f8f9fa;
|
||||
@ -434,16 +207,6 @@ const activeFilter = ref("all");
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-container::before {
|
||||
left: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-badge {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref } from "vue";
|
||||
import LocationModal from "../location/LocationModal.vue";
|
||||
import LocationModal from "../Location/LocationModal.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
defineProps({
|
||||
|
||||
@ -10,6 +10,7 @@ Coded by www.creative-tim.com
|
||||
*/
|
||||
|
||||
import { createApp } from "vue";
|
||||
import "bootstrap/dist/js/bootstrap.bundle.min.js";
|
||||
import App from "./App.vue";
|
||||
import store from "./store";
|
||||
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) => {
|
||||
if (meta) {
|
||||
const getValue = (val: any) => (Array.isArray(val) ? val[0] : val);
|
||||
pagination.value = {
|
||||
current_page: meta.current_page || 1,
|
||||
last_page: meta.last_page || 1,
|
||||
per_page: meta.per_page || 10,
|
||||
total: meta.total || 0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -98,6 +99,7 @@ export const useClientStore = defineStore("client", () => {
|
||||
|
||||
try {
|
||||
const response = await ClientService.getAllClients(params);
|
||||
console.log('API Response:', response);
|
||||
setClients(response.data);
|
||||
if (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-data="clientStore.clients"
|
||||
:loading-data="clientStore.loading"
|
||||
:pagination="clientStore.getPagination"
|
||||
@push-details="goDetails"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="card" :class="darkMode ? 'bg-gradient-dark' : ''">
|
||||
<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 -->
|
||||
<p class="text-sm" v-html="description"></p>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user