FEAT: activite client=

This commit is contained in:
kevin 2026-01-12 16:37:41 +03:00
parent 503fb0d008
commit c00ce5ab94
33 changed files with 1036 additions and 452 deletions

View File

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

View File

@ -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'),

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(), [

View File

@ -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(), [

View 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(),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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', '.*');

View File

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

View File

@ -17,7 +17,7 @@
</div>
<div v-show="activeTab === 'activity'">
<ClientActivityTab />
<ClientActivityTab :client-id="clientId" :active-tab="activeTab" />
</div>
<!-- Contacts Tab -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

@ -2,6 +2,7 @@
<client-presentation
:client-data="clientStore.clients"
:loading-data="clientStore.loading"
:pagination="clientStore.getPagination"
@push-details="goDetails"
/>
</template>

View File

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