FIX: Creation demande, client on clique show
This commit is contained in:
parent
11750a3ffc
commit
8074ac4f48
@ -5,9 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssignClientsToGroupRequest;
|
||||
use App\Http\Requests\StoreClientGroupRequest;
|
||||
use App\Http\Requests\UpdateClientGroupRequest;
|
||||
use App\Http\Resources\Client\ClientGroupResource;
|
||||
use App\Models\Client;
|
||||
use App\Repositories\ClientGroupRepositoryInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@ -153,4 +155,44 @@ class ClientGroupController extends Controller
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign many clients to one client group.
|
||||
*/
|
||||
public function assignClients(AssignClientsToGroupRequest $request, string $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$clientGroup = $this->clientGroupRepository->find($id);
|
||||
|
||||
if (!$clientGroup) {
|
||||
return response()->json([
|
||||
'message' => 'Groupe de clients non trouvé.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$clientIds = $request->validated('client_ids');
|
||||
|
||||
$updatedCount = Client::query()
|
||||
->whereIn('id', $clientIds)
|
||||
->update(['group_id' => $clientGroup->id]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Clients assignés au groupe avec succès.',
|
||||
'assigned_count' => $updatedCount,
|
||||
'group' => new ClientGroupResource($clientGroup),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error assigning clients to group: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'client_group_id' => $id,
|
||||
'data' => $request->validated(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de l’assignation des clients au groupe.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AssignClientsToGroupRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'client_ids' => 'required|array|min:1',
|
||||
'client_ids.*' => 'integer|distinct|exists:clients,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'client_ids.required' => 'La liste des clients est obligatoire.',
|
||||
'client_ids.array' => 'La liste des clients doit être un tableau.',
|
||||
'client_ids.min' => 'Veuillez sélectionner au moins un client.',
|
||||
'client_ids.*.integer' => 'Chaque ID client doit être un entier.',
|
||||
'client_ids.*.distinct' => 'Un client ne peut pas être envoyé plusieurs fois.',
|
||||
'client_ids.*.exists' => 'Un ou plusieurs clients sélectionnés sont introuvables.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,24 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ClientGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'price_list_id',
|
||||
];
|
||||
|
||||
public function priceList(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PriceList::class, 'price_list_id');
|
||||
}
|
||||
|
||||
public function clients(): HasMany
|
||||
{
|
||||
return $this->hasMany(Client::class, 'group_id');
|
||||
}
|
||||
}
|
||||
|
||||
36
thanasoft-back/app/Models/PriceList.php
Normal file
36
thanasoft-back/app/Models/PriceList.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PriceList extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'is_default',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_from' => 'date',
|
||||
'valid_to' => 'date',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
public function clientGroups(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientGroup::class, 'price_list_id');
|
||||
}
|
||||
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'product_price_list')
|
||||
->withPivot('price')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Product extends Model
|
||||
@ -131,6 +132,16 @@ class Product extends Model
|
||||
return $this->hasMany(GoodsReceiptLine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Price lists attached to this product with custom price.
|
||||
*/
|
||||
public function priceLists(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PriceList::class, 'product_price_list')
|
||||
->withPivot('price')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model
|
||||
*/
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?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('price_lists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 191);
|
||||
$table->date('valid_from')->nullable();
|
||||
$table->date('valid_to')->nullable();
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('price_lists');
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_groups', function (Blueprint $table) {
|
||||
$table->foreignId('price_list_id')
|
||||
->nullable()
|
||||
->after('description')
|
||||
->constrained('price_lists')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_groups', function (Blueprint $table) {
|
||||
$table->dropForeign(['price_list_id']);
|
||||
$table->dropColumn('price_list_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_price_list', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
|
||||
$table->foreignId('price_list_id')->constrained('price_lists')->cascadeOnDelete();
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['product_id', 'price_list_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_price_list');
|
||||
}
|
||||
};
|
||||
|
||||
@ -55,6 +55,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/clients/searchBy', [ClientController::class, 'searchBy']);
|
||||
|
||||
Route::apiResource('clients', ClientController::class);
|
||||
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
|
||||
Route::apiResource('client-groups', ClientGroupController::class);
|
||||
|
||||
Route::apiResource('client-locations', ClientLocationController::class);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<fournisseur-template>
|
||||
|
||||
|
||||
<template #summary-cards>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
|
||||
@ -6,9 +6,13 @@
|
||||
<client-group-table
|
||||
:data="clientGroups"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@view="handleView"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@page-change="onPageChange"
|
||||
@per-page-change="onPerPageChange"
|
||||
@search-change="onSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -27,7 +31,7 @@ import { useNotificationStore } from "@/stores/notification";
|
||||
const router = useRouter();
|
||||
const clientGroupStore = useClientGroupStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const { clientGroups, loading } = storeToRefs(clientGroupStore);
|
||||
const { clientGroups, loading, pagination } = storeToRefs(clientGroupStore);
|
||||
|
||||
const openCreateModal = () => {
|
||||
router.push("/clients/groups/new");
|
||||
@ -62,6 +66,28 @@ const handleDelete = async (id) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (page) => {
|
||||
clientGroupStore.fetchClientGroups({
|
||||
page,
|
||||
per_page: pagination.value.per_page,
|
||||
});
|
||||
};
|
||||
|
||||
const onPerPageChange = (perPage) => {
|
||||
clientGroupStore.fetchClientGroups({
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const onSearch = (query) => {
|
||||
clientGroupStore.fetchClientGroups({
|
||||
page: 1,
|
||||
per_page: pagination.value.per_page,
|
||||
search: query,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
clientGroupStore.fetchClientGroups();
|
||||
});
|
||||
|
||||
@ -270,7 +270,8 @@ const secondaryActionTargetStatus = computed(() => {
|
||||
|
||||
const secondaryActionLabel = computed(() => {
|
||||
if (secondaryActionTargetStatus.value === "annulee") return "Annuler";
|
||||
if (secondaryActionTargetStatus.value === "brouillon") return "Remettre en brouillon";
|
||||
if (secondaryActionTargetStatus.value === "brouillon")
|
||||
return "Remettre en brouillon";
|
||||
return "Aucune action";
|
||||
});
|
||||
|
||||
|
||||
@ -11,13 +11,24 @@
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="planningNewRequestModalLabel" class="modal-title">Nouvelle demande</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="$emit('close')"></button>
|
||||
<h5 id="planningNewRequestModalLabel" class="modal-title">
|
||||
Nouvelle demande
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p v-if="!creationType" class="text-sm text-muted mb-3">Choisissez le type à créer :</p>
|
||||
<p v-else class="text-sm text-muted mb-3">{{ creationTypeTitle }}</p>
|
||||
<p v-if="!creationType" class="text-sm text-muted mb-3">
|
||||
Choisissez le type à créer :
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted mb-3">
|
||||
{{ creationTypeTitle }}
|
||||
</p>
|
||||
|
||||
<planning-creation-type-selector
|
||||
v-if="!creationType"
|
||||
@ -45,7 +56,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="show" class="modal-backdrop fade show" @click="$emit('close')"></div>
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-backdrop fade show"
|
||||
@click="$emit('close')"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -93,4 +108,3 @@ defineEmits([
|
||||
"update:event-form",
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
||||
@ -95,11 +95,16 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Date réception</label>
|
||||
<div class="info-value">{{ formatDate(goodsReceipt.receipt_date) }}</div>
|
||||
<div class="info-value">
|
||||
{{ formatDate(goodsReceipt.receipt_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Statut</label>
|
||||
<div class="status-badge" :class="getStatusClass(goodsReceipt.status)">
|
||||
<div
|
||||
class="status-badge"
|
||||
:class="getStatusClass(goodsReceipt.status)"
|
||||
>
|
||||
<i :class="getStatusIcon(goodsReceipt.status)"></i>
|
||||
{{ getStatusLabel(goodsReceipt.status) }}
|
||||
</div>
|
||||
@ -110,12 +115,17 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Commande fournisseur</label>
|
||||
<div class="info-value">
|
||||
{{ goodsReceipt.purchase_order?.po_number || goodsReceipt.purchase_order_id }}
|
||||
{{
|
||||
goodsReceipt.purchase_order?.po_number ||
|
||||
goodsReceipt.purchase_order_id
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Entrepôt</label>
|
||||
<div class="info-value">{{ goodsReceipt.warehouse?.name || "-" }}</div>
|
||||
<div class="info-value">
|
||||
{{ goodsReceipt.warehouse?.name || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -142,16 +152,26 @@
|
||||
<tr v-for="line in receiptLines" :key="line.id">
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-sm fw-bold">{{ line.product?.nom || `Produit #${line.product_id}` }}</span>
|
||||
<span class="text-xs text-secondary">{{ line.product?.reference || "-" }}</span>
|
||||
<span class="text-sm fw-bold">{{
|
||||
line.product?.nom || `Produit #${line.product_id}`
|
||||
}}</span>
|
||||
<span class="text-xs text-secondary">{{
|
||||
line.product?.reference || "-"
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm">{{ line.packaging?.name || "Unité" }}</td>
|
||||
<td class="text-sm">{{ line.packages_qty_received || "-" }}</td>
|
||||
<td class="text-sm">{{ line.units_qty_received || "-" }}</td>
|
||||
<td class="text-sm">{{ line.unit_price ? formatCurrency(line.unit_price) : "-" }}</td>
|
||||
<td class="text-sm">
|
||||
{{ line.tva_rate ? `${line.tva_rate.name} (${line.tva_rate.rate}%)` : "-" }}
|
||||
{{ line.unit_price ? formatCurrency(line.unit_price) : "-" }}
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{{
|
||||
line.tva_rate
|
||||
? `${line.tva_rate.name} (${line.tva_rate.rate}%)`
|
||||
: "-"
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="receiptLines.length === 0">
|
||||
@ -178,7 +198,9 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const goodsReceiptStore = useGoodsReceiptStore();
|
||||
|
||||
const { currentGoodsReceipt: goodsReceipt, loading, error } = storeToRefs(goodsReceiptStore);
|
||||
const { currentGoodsReceipt: goodsReceipt, loading, error } = storeToRefs(
|
||||
goodsReceiptStore
|
||||
);
|
||||
|
||||
const isUpdatingStatus = ref(false);
|
||||
|
||||
@ -271,7 +293,11 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer cette réception de marchandise ?")) {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir supprimer cette réception de marchandise ?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@ -1,17 +1,28 @@
|
||||
<template>
|
||||
<div class="d-grid gap-2">
|
||||
<soft-button color="info" variant="gradient" @click="$emit('select-type', 'intervention')">
|
||||
<i class="fas fa-briefcase-medical me-2"></i>
|
||||
<soft-button
|
||||
color="info"
|
||||
@click="$emit('select-type', 'intervention')"
|
||||
>
|
||||
|
||||
Créer une intervention
|
||||
</soft-button>
|
||||
|
||||
<soft-button color="warning" variant="gradient" @click="$emit('select-type', 'leave')">
|
||||
<i class="fas fa-umbrella-beach me-2"></i>
|
||||
<soft-button
|
||||
color="warning"
|
||||
|
||||
@click="$emit('select-type', 'leave')"
|
||||
>
|
||||
|
||||
Demande de congé employé
|
||||
</soft-button>
|
||||
|
||||
<soft-button color="success" variant="gradient" @click="$emit('select-type', 'event')">
|
||||
<i class="fas fa-calendar-plus me-2"></i>
|
||||
<soft-button
|
||||
color="success"
|
||||
|
||||
@click="$emit('select-type', 'event')"
|
||||
>
|
||||
|
||||
Créer un événement
|
||||
</soft-button>
|
||||
</div>
|
||||
@ -23,4 +34,3 @@ import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
defineEmits(["select-type"]);
|
||||
</script>
|
||||
|
||||
|
||||
@ -39,8 +39,12 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-end pt-2">
|
||||
<soft-button color="secondary" variant="outline" @click="$emit('back')">Retour</soft-button>
|
||||
<soft-button color="success" variant="gradient" type="submit">Enregistrer</soft-button>
|
||||
<soft-button color="secondary" variant="outline" @click="$emit('back')"
|
||||
>Retour</soft-button
|
||||
>
|
||||
<soft-button color="success" variant="gradient" type="submit"
|
||||
>Enregistrer</soft-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@ -62,4 +66,3 @@ const updateField = (field, value) => {
|
||||
emit("update:form", { field, value });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,16 +1,136 @@
|
||||
<template>
|
||||
<div class="planning-kanban-container mt-3">
|
||||
<div class="planning-kanban-scroll py-2">
|
||||
<div id="planningKanban"></div>
|
||||
<div class="planning-kanban-root">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="kanban-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="toolbar-stat" v-for="col in columnsConfig" :key="col.id">
|
||||
<span class="stat-dot" :style="{ background: col.color }"></span>
|
||||
<span class="stat-label">{{ col.title }}</span>
|
||||
<span class="stat-count">{{ countByStatus(col.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="total-badge">
|
||||
<i class="fas fa-list-ul me-1"></i>
|
||||
{{ interventions.length }} intervention{{ interventions.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="kanban-scroll-area">
|
||||
<div class="kanban-columns">
|
||||
|
||||
<div
|
||||
v-for="col in columnsConfig"
|
||||
:key="col.id"
|
||||
class="kanban-column"
|
||||
:data-col-id="col.id"
|
||||
>
|
||||
<!-- Column Header -->
|
||||
<div class="col-header" :style="{ '--col-color': col.color }">
|
||||
<div class="col-header-top">
|
||||
<div class="col-title-row">
|
||||
<span class="col-dot"></span>
|
||||
<span class="col-title">{{ col.title }}</span>
|
||||
</div>
|
||||
<span class="col-badge">{{ countByStatus(col.status) }}</span>
|
||||
</div>
|
||||
<div class="col-progress-bar">
|
||||
<div
|
||||
class="col-progress-fill"
|
||||
:style="{ width: progressWidth(col.status) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Drop Zone -->
|
||||
<div
|
||||
class="cards-zone"
|
||||
:data-status="col.status"
|
||||
@dragover.prevent="onDragOver($event, col.id)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop="onDrop($event, col)"
|
||||
>
|
||||
<transition-group name="card-list" tag="div">
|
||||
<div
|
||||
v-for="item in itemsByStatus(col.status)"
|
||||
:key="item.id"
|
||||
class="kanban-card"
|
||||
:class="{ 'is-dragging': draggingId === item.id.toString() }"
|
||||
:style="{ '--type-color': getTypeColor(item.type) }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
@dragend="onDragEnd"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<!-- Left accent strip -->
|
||||
<div class="card-strip"></div>
|
||||
|
||||
<!-- Card Body -->
|
||||
<div class="card-inner">
|
||||
<!-- Top row: type badge + time -->
|
||||
<div class="card-top">
|
||||
<span class="type-badge">
|
||||
<i :class="getTypeIcon(item.type)" class="type-icon"></i>
|
||||
{{ item.type || 'Soin' }}
|
||||
</span>
|
||||
<span class="card-time">
|
||||
<i class="far fa-clock time-icon"></i>
|
||||
{{ formatTime(item.date) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Deceased name -->
|
||||
<div class="card-deceased">
|
||||
{{ item.deceased || 'Non spécifié' }}
|
||||
</div>
|
||||
|
||||
<!-- Client -->
|
||||
<div class="card-client">
|
||||
<i class="fas fa-user card-client-icon"></i>
|
||||
<span>{{ item.client || '–' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: date + collaborator -->
|
||||
<div class="card-footer-row">
|
||||
<div class="card-date">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
<span>{{ formatDate(item.date) }}</span>
|
||||
</div>
|
||||
<div class="collab-avatar" :title="item.collaborator">
|
||||
{{ getInitials(item.collaborator) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hover action bar -->
|
||||
<div class="card-hover-bar">
|
||||
<button class="hover-action" @click.stop="emit('edit', item)" title="Modifier">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<div class="hover-divider"></div>
|
||||
<span class="hover-hint">Glisser pour déplacer</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="itemsByStatus(col.status).length === 0" class="empty-column">
|
||||
<i class="fas fa-inbox empty-icon"></i>
|
||||
<span>Aucune intervention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable */
|
||||
import { onMounted, watch, defineProps, defineEmits } from "vue";
|
||||
import "jkanban/dist/jkanban.min.js";
|
||||
import "jkanban/dist/jkanban.min.css";
|
||||
import { ref, defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
interventions: {
|
||||
@ -21,195 +141,513 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["edit", "update-status"]);
|
||||
|
||||
let kanbanInstance = null;
|
||||
const draggingId = ref(null);
|
||||
const draggingItem = ref(null);
|
||||
const dragOverCol = ref(null);
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'À planifier', status: 'En attente', colorClass: 'warning' },
|
||||
{ id: 'planned', title: 'Confirmé', status: 'Confirmé', colorClass: 'info' },
|
||||
{ id: 'in-progress', title: 'En cours', status: 'En cours', colorClass: 'primary' },
|
||||
{ id: 'done', title: 'Terminé', status: 'Terminé', colorClass: 'success' }
|
||||
const columnsConfig = [
|
||||
{ id: 'todo', title: 'À planifier', status: 'En attente', color: '#f59e0b', icon: 'fas fa-hourglass-half' },
|
||||
{ id: 'planned', title: 'Confirmé', status: 'Confirmé', color: '#3b82f6', icon: 'fas fa-calendar-check' },
|
||||
{ id: 'in-progress', title: 'En cours', status: 'En cours', color: '#8b5cf6', icon: 'fas fa-bolt' },
|
||||
{ id: 'done', title: 'Terminé', status: 'Terminé', color: '#10b981', icon: 'fas fa-check-circle' },
|
||||
];
|
||||
|
||||
const getBoards = () => {
|
||||
return columns.map(col => ({
|
||||
id: col.id,
|
||||
title: col.title,
|
||||
class: col.colorClass, // custom class for header styling if needed
|
||||
item: props.interventions
|
||||
.filter(i => i.status === col.status)
|
||||
.map(i => ({
|
||||
id: i.id.toString(),
|
||||
title: buildCardHtml(i),
|
||||
originalData: i // Store original data for easy access
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
const buildCardHtml = (item) => {
|
||||
// Helpers
|
||||
const formatTime = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const formatDate = (d) => new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
const getInitials = (n) => n ? n.split(' ').map(s => s[0]).join('').substring(0, 2).toUpperCase() : "?";
|
||||
|
||||
const typeColors = {
|
||||
const typeColors = {
|
||||
'Soin': '#3b82f6',
|
||||
'Transport': '#10b981',
|
||||
'Mise en bière': '#f59e0b',
|
||||
'Cérémonie': '#8b5cf6'
|
||||
};
|
||||
const typeColor = typeColors[item.type] || '#6b7280';
|
||||
|
||||
return `
|
||||
<div class="card-content">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="badge badge-sm bg-gradient-light text-dark border py-1">${item.type}</span>
|
||||
<span class="text-xs font-weight-bold text-secondary">${formatTime(item.date)}</span>
|
||||
</div>
|
||||
<h6 class="mb-1 text-sm font-weight-bold">${item.deceased || 'Non spécifié'}</h6>
|
||||
<p class="text-xs text-secondary mb-3 text-truncate">${item.client || '-'}</p>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between border-top pt-2">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<i class="far fa-calendar text-xs text-secondary"></i>
|
||||
<span class="text-xs text-secondary">${formatDate(item.date)}</span>
|
||||
</div>
|
||||
<div class="avatar avatar-xs rounded-circle bg-gray-200 text-xs d-flex align-items-center justify-content-center" title="${item.collaborator}">
|
||||
${getInitials(item.collaborator)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-left-strip" style="background-color: ${typeColor}; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;"></div>
|
||||
</div>
|
||||
`;
|
||||
'Cérémonie': '#8b5cf6',
|
||||
};
|
||||
|
||||
const initKanban = () => {
|
||||
const boards = getBoards();
|
||||
|
||||
// Clean up existing if any (jkanban doesn't have a destroy method easily accessible, but we can empty container)
|
||||
const container = document.getElementById("planningKanban");
|
||||
if (container) container.innerHTML = "";
|
||||
|
||||
kanbanInstance = new jKanban({
|
||||
element: "#planningKanban",
|
||||
gutter: "10px",
|
||||
widthBoard: "360px",
|
||||
responsivePercentage: false,
|
||||
dragItems: true,
|
||||
boards: boards,
|
||||
click: (el) => {
|
||||
const id = el.getAttribute("data-eid");
|
||||
const item = props.interventions.find(i => i.id.toString() === id);
|
||||
if (item) {
|
||||
emit("edit", item);
|
||||
}
|
||||
},
|
||||
dropEl: (el, target, source, sibling) => {
|
||||
const id = el.getAttribute("data-eid");
|
||||
const targetBoardId = target.parentElement.getAttribute("data-id");
|
||||
|
||||
// Find new status based on target board ID
|
||||
const targetCol = columns.find(c => c.id === targetBoardId);
|
||||
if (targetCol) {
|
||||
emit("update-status", { id, status: targetCol.status });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply custom styling adjustments after render
|
||||
applyCustomStyles();
|
||||
const typeIcons = {
|
||||
'Soin': 'fas fa-heartbeat',
|
||||
'Transport': 'fas fa-car',
|
||||
'Mise en bière': 'fas fa-box',
|
||||
'Cérémonie': 'fas fa-dove',
|
||||
};
|
||||
|
||||
const applyCustomStyles = () => {
|
||||
// Helper to add Bootstrap classes or custom styles to jKanban generated elements
|
||||
const headers = document.querySelectorAll('.kanban-board-header');
|
||||
headers.forEach(header => {
|
||||
header.classList.add('bg-transparent', 'border-0', 'pb-2');
|
||||
// Add dot
|
||||
const boardId = header.parentElement.getAttribute('data-id');
|
||||
const col = columns.find(c => c.id === boardId);
|
||||
if(col) {
|
||||
// Logic to inject the dot and count if we want to replicate the exact header design
|
||||
// Note: jKanban header usually just contains title text. We might want to customize title HTML in getBoards().
|
||||
}
|
||||
});
|
||||
const getTypeColor = (type) => typeColors[type] || '#6b7280';
|
||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
||||
|
||||
const formatTime = (d) => {
|
||||
try { return new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); }
|
||||
catch { return '--:--'; }
|
||||
};
|
||||
const formatDate = (d) => {
|
||||
try { return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); }
|
||||
catch { return '–'; }
|
||||
};
|
||||
const getInitials = (n) => n ? n.split(' ').map(s => s[0]).join('').substring(0, 2).toUpperCase() : '?';
|
||||
|
||||
const itemsByStatus = (status) => props.interventions.filter(i => i.status === status);
|
||||
const countByStatus = (status) => itemsByStatus(status).length;
|
||||
const progressWidth = (status) => {
|
||||
if (!props.interventions.length) return 0;
|
||||
return Math.round((countByStatus(status) / props.interventions.length) * 100);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Give DOM a moment
|
||||
setTimeout(() => {
|
||||
initKanban();
|
||||
}, 100);
|
||||
});
|
||||
// ─── Native Drag & Drop ───
|
||||
const onDragStart = (e, item) => {
|
||||
draggingId.value = item.id.toString();
|
||||
draggingItem.value = item;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', item.id.toString());
|
||||
};
|
||||
|
||||
watch(() => props.interventions, () => {
|
||||
// Re-init on data change
|
||||
// Optimization: use kanbanInstance methods to add/remove, but full re-init is safer for consistency for now
|
||||
initKanban();
|
||||
}, { deep: true });
|
||||
const onDragEnd = () => {
|
||||
draggingId.value = null;
|
||||
draggingItem.value = null;
|
||||
dragOverCol.value = null;
|
||||
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
|
||||
};
|
||||
|
||||
const onDragOver = (e, colId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverCol.value !== colId) {
|
||||
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
|
||||
dragOverCol.value = colId;
|
||||
e.currentTarget.classList.add('drag-over');
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e, col) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const id = e.dataTransfer.getData('text/plain');
|
||||
if (id && draggingItem.value) {
|
||||
emit('update-status', { id, status: col.status });
|
||||
}
|
||||
draggingId.value = null;
|
||||
draggingItem.value = null;
|
||||
dragOverCol.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Global styles for jKanban overrides */
|
||||
.kanban-container {
|
||||
<style scoped>
|
||||
/* ─── Root ─── */
|
||||
.planning-kanban-root {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: visible !important;
|
||||
overflow-y: hidden !important;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 180px);
|
||||
min-height: 500px;
|
||||
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
width: 360px !important;
|
||||
/* ─── Toolbar ─── */
|
||||
.kanban-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 4px 14px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.stat-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.total-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
/* ─── Scroll Area ─── */
|
||||
.kanban-scroll-area {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.kanban-scroll-area::-webkit-scrollbar { height: 5px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
.kanban-scroll-area::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* ─── Columns ─── */
|
||||
.kanban-columns {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-width: max-content;
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.kanban-board-header {
|
||||
padding-bottom: 10px !important;
|
||||
/* ─── Column Header ─── */
|
||||
.col-header {
|
||||
background: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-bottom: none;
|
||||
padding: 13px 14px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kanban-item {
|
||||
background: white !important;
|
||||
border-radius: 0.75rem !important; /* rounded-xl */
|
||||
margin-bottom: 10px !important;
|
||||
padding: 1rem !important;
|
||||
box-shadow: 0 .125rem .25rem rgba(0,0,0,.075) !important;
|
||||
border: 0 !important;
|
||||
position: relative;
|
||||
.col-header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.col-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.col-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--col-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--col-color) 20%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.col-badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--col-color) 12%, transparent);
|
||||
color: var(--col-color);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.col-progress-bar {
|
||||
height: 3px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kanban-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.kanban-drag {
|
||||
min-height: 100%;
|
||||
background-color: #f1f5f9; /* slate-100 */
|
||||
border-radius: 0.75rem;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.planning-kanban-container {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.planning-kanban-scroll {
|
||||
width: 100%;
|
||||
height: calc(100vh - 220px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
#planningKanban {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
.col-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--col-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* ─── Cards Zone ─── */
|
||||
.cards-zone {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
transition: background 0.2s;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.cards-zone::-webkit-scrollbar { width: 4px; }
|
||||
.cards-zone::-webkit-scrollbar-track { background: transparent; }
|
||||
.cards-zone::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
|
||||
|
||||
.cards-zone.drag-over {
|
||||
background: color-mix(in srgb, #3b82f6 6%, #f8fafc);
|
||||
border-color: #93c5fd;
|
||||
box-shadow: inset 0 0 0 2px #bfdbfe;
|
||||
}
|
||||
|
||||
/* ─── Card ─── */
|
||||
.kanban-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 9px;
|
||||
border: 1px solid #e9edf2;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||
border-color: #dde3ea;
|
||||
}
|
||||
|
||||
.kanban-card:active,
|
||||
.kanban-card.is-dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.kanban-card:hover .card-hover-bar {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Left accent strip */
|
||||
.card-strip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--type-color);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
/* ─── Card Inner ─── */
|
||||
.card-inner {
|
||||
padding: 11px 12px 11px 16px;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--type-color);
|
||||
background: color-mix(in srgb, var(--type-color) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--type-color) 20%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.time-icon {
|
||||
color: #9ca3af;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.card-deceased {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-client {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.74rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-client-icon {
|
||||
font-size: 0.65rem;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.73rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.card-date i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.collab-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #1a2e4a, #2d4a6e);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Hover Action Bar ─── */
|
||||
.card-hover-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 16px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.hover-action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.72rem;
|
||||
color: #2d4a6e;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hover-action:hover {
|
||||
background: #e8eef7;
|
||||
}
|
||||
|
||||
.hover-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.hover-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #9ca3af;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
.empty-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px 16px;
|
||||
color: #cbd5e1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 1.6rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-column span {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Transition animations ─── */
|
||||
.card-list-enter-active,
|
||||
.card-list-leave-active {
|
||||
transition: all 0.28s ease;
|
||||
}
|
||||
|
||||
.card-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.97);
|
||||
}
|
||||
|
||||
.card-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px) scale(0.96);
|
||||
}
|
||||
|
||||
.card-list-move {
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
</style>
|
||||
@ -2,9 +2,17 @@
|
||||
<form class="d-grid gap-2" @submit.prevent="$emit('submit')">
|
||||
<div>
|
||||
<label class="form-label">Employé</label>
|
||||
<select :value="form.employee" class="form-select" @change="updateField('employee', $event.target.value)">
|
||||
<select
|
||||
:value="form.employee"
|
||||
class="form-select"
|
||||
@change="updateField('employee', $event.target.value)"
|
||||
>
|
||||
<option value="" disabled>Choisir un employé</option>
|
||||
<option v-for="collab in collaborators" :key="collab.id" :value="collab.name">
|
||||
<option
|
||||
v-for="collab in collaborators"
|
||||
:key="collab.id"
|
||||
:value="collab.name"
|
||||
>
|
||||
{{ collab.name }}
|
||||
</option>
|
||||
</select>
|
||||
@ -13,11 +21,19 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Du</label>
|
||||
<soft-input :model-value="form.startDate" type="date" @update:model-value="updateField('startDate', $event)" />
|
||||
<soft-input
|
||||
:model-value="form.startDate"
|
||||
type="date"
|
||||
@update:model-value="updateField('startDate', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Au</label>
|
||||
<soft-input :model-value="form.endDate" type="date" @update:model-value="updateField('endDate', $event)" />
|
||||
<soft-input
|
||||
:model-value="form.endDate"
|
||||
type="date"
|
||||
@update:model-value="updateField('endDate', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,8 +47,12 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-end pt-2">
|
||||
<soft-button color="secondary" variant="outline" @click="$emit('back')">Retour</soft-button>
|
||||
<soft-button color="warning" variant="gradient" type="submit">Enregistrer</soft-button>
|
||||
<soft-button color="secondary" variant="outline" @click="$emit('back')"
|
||||
>Retour</soft-button
|
||||
>
|
||||
<soft-button color="warning" variant="gradient" type="submit"
|
||||
>Enregistrer</soft-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@ -58,4 +78,3 @@ const updateField = (field, value) => {
|
||||
emit("update:form", { field, value });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,27 +1,84 @@
|
||||
<template>
|
||||
<div class="calendar-grid-container card h-100 border-0 shadow-sm rounded-xl">
|
||||
<div class="card-body p-3 h-100">
|
||||
<div id="fullCalendarGrid" ref="calendarEl" class="h-100"></div>
|
||||
<div class="calendar-root">
|
||||
|
||||
<!-- Custom Header / Navigation -->
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
<div class="week-label">
|
||||
<i class="fas fa-calendar-week week-icon"></i>
|
||||
<span class="week-range">{{ weekRangeLabel }}</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<span v-for="(color, type) in typeColors" :key="type" class="legend-item">
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
{{ type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="nav-btn" @click="navigate(-1)" title="Semaine précédente">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="today-btn" @click="goToday">Aujourd'hui</button>
|
||||
<button class="nav-btn" @click="navigate(1)" title="Semaine suivante">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Wrapper -->
|
||||
<div class="calendar-body">
|
||||
<div ref="calendarEl" class="fc-wrapper"></div>
|
||||
</div>
|
||||
|
||||
<!-- Event Detail Popover -->
|
||||
<transition name="popover-fade">
|
||||
<div
|
||||
v-if="activePopover"
|
||||
class="event-popover"
|
||||
:style="popoverStyle"
|
||||
@click.stop
|
||||
>
|
||||
<div class="popover-strip" :style="{ background: activePopover.color }"></div>
|
||||
<div class="popover-body">
|
||||
<div class="popover-top">
|
||||
<span class="popover-type" :style="{ color: activePopover.color, background: activePopover.color + '18' }">
|
||||
<i :class="getTypeIcon(activePopover.type)" class="me-1"></i>
|
||||
{{ activePopover.type }}
|
||||
</span>
|
||||
<button class="popover-close" @click="closePopover"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="popover-deceased">{{ activePopover.deceased || 'Non spécifié' }}</div>
|
||||
<div class="popover-meta">
|
||||
<span><i class="fas fa-user me-1"></i>{{ activePopover.client || '–' }}</span>
|
||||
<span><i class="far fa-clock me-1"></i>{{ activePopover.timeLabel }}</span>
|
||||
</div>
|
||||
<div class="popover-status">
|
||||
<span class="status-pill" :style="{ background: activePopover.color + '20', color: activePopover.color }">
|
||||
{{ activePopover.status || 'Planifié' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="popover-edit-btn" @click="editFromPopover">
|
||||
<i class="fas fa-pen me-2"></i>Modifier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div v-if="activePopover" class="popover-backdrop" @click="closePopover"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, defineProps, defineEmits } from "vue";
|
||||
import { ref, onMounted, onUnmounted, watch, computed, defineProps, defineEmits } from "vue";
|
||||
import { Calendar } from "@fullcalendar/core";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import frLocale from "@fullcalendar/core/locales/fr";
|
||||
|
||||
const props = defineProps({
|
||||
startDate: {
|
||||
type: Date,
|
||||
default: () => new Date(),
|
||||
},
|
||||
interventions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
startDate: { type: Date, default: () => new Date() },
|
||||
interventions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["cell-click", "edit"]);
|
||||
@ -29,137 +86,558 @@ const emit = defineEmits(["cell-click", "edit"]);
|
||||
const calendarEl = ref(null);
|
||||
let calendar = null;
|
||||
|
||||
const initializeCalendar = () => {
|
||||
if (calendar) {
|
||||
calendar.destroy();
|
||||
}
|
||||
const activePopover = ref(null);
|
||||
const popoverStyle = ref({});
|
||||
const currentDate = ref(props.startDate);
|
||||
|
||||
const typeColors = {
|
||||
'Soin': '#3b82f6',
|
||||
'Transport': '#10b981',
|
||||
'Mise en bière': '#f59e0b',
|
||||
'Cérémonie': '#8b5cf6',
|
||||
};
|
||||
|
||||
const typeIcons = {
|
||||
'Soin': 'fas fa-heartbeat',
|
||||
'Transport': 'fas fa-car',
|
||||
'Mise en bière': 'fas fa-box',
|
||||
'Cérémonie': 'fas fa-dove',
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
|
||||
|
||||
const weekRangeLabel = computed(() => {
|
||||
if (!calendar) return '';
|
||||
try {
|
||||
const view = calendar.view;
|
||||
const start = view.currentStart;
|
||||
const end = new Date(view.currentEnd);
|
||||
end.setDate(end.getDate() - 1);
|
||||
const fmt = (d) => d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
return `${fmt(start)} – ${fmt(end)} ${start.getFullYear()}`;
|
||||
} catch { return ''; }
|
||||
});
|
||||
|
||||
const mapEvents = (interventions) => interventions.map((i) => {
|
||||
const color = typeColors[i.type] || '#6b7280';
|
||||
const start = new Date(i.date);
|
||||
const end = i.end ? new Date(i.end) : new Date(start.getTime() + 60 * 60 * 1000);
|
||||
return {
|
||||
id: String(i.id),
|
||||
title: i.deceased || i.type || 'Intervention',
|
||||
start, end,
|
||||
backgroundColor: color + 'dd',
|
||||
borderColor: color,
|
||||
textColor: '#fff',
|
||||
extendedProps: { originalData: i, color },
|
||||
};
|
||||
});
|
||||
|
||||
const showPopover = (jsEvent, originalData, color) => {
|
||||
const rect = jsEvent.target.closest('.fc-event')?.getBoundingClientRect() || jsEvent.target.getBoundingClientRect();
|
||||
const rootRect = calendarEl.value.getBoundingClientRect();
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
activePopover.value = {
|
||||
...originalData,
|
||||
color,
|
||||
timeLabel: fmt(originalData.date),
|
||||
};
|
||||
|
||||
// Position popover near the event
|
||||
let left = rect.right - rootRect.left + 8;
|
||||
let top = rect.top - rootRect.top;
|
||||
|
||||
// Clamp right edge
|
||||
const popW = 260;
|
||||
if (left + popW > rootRect.width) {
|
||||
left = rect.left - rootRect.left - popW - 8;
|
||||
}
|
||||
if (left < 0) left = 8;
|
||||
if (top + 200 > rootRect.height) top = rootRect.height - 210;
|
||||
if (top < 0) top = 8;
|
||||
|
||||
popoverStyle.value = { left: left + 'px', top: top + 'px' };
|
||||
};
|
||||
|
||||
const closePopover = () => { activePopover.value = null; };
|
||||
|
||||
const editFromPopover = () => {
|
||||
if (activePopover.value) {
|
||||
emit('edit', activePopover.value);
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!calendar) return;
|
||||
dir === 1 ? calendar.next() : calendar.prev();
|
||||
// Force reactive update of label
|
||||
currentDate.value = calendar.getDate();
|
||||
};
|
||||
|
||||
const goToday = () => {
|
||||
if (!calendar) return;
|
||||
calendar.today();
|
||||
currentDate.value = calendar.getDate();
|
||||
};
|
||||
|
||||
const initCalendar = () => {
|
||||
if (calendar) calendar.destroy();
|
||||
if (!calendarEl.value) return;
|
||||
|
||||
calendar = new Calendar(calendarEl.value, {
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: "timeGridWeek",
|
||||
initialView: 'timeGridWeek',
|
||||
locale: frLocale,
|
||||
headerToolbar: false, // Hidden header, controlled by parent navigator
|
||||
headerToolbar: false,
|
||||
initialDate: props.startDate,
|
||||
allDaySlot: false,
|
||||
slotMinTime: "07:00:00",
|
||||
slotMaxTime: "21:00:00",
|
||||
height: "auto",
|
||||
contentHeight: "auto",
|
||||
slotMinTime: '07:00:00',
|
||||
slotMaxTime: '21:00:00',
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
stickyHeaderDates: true,
|
||||
dayHeaderFormat: { weekday: "long", day: "numeric", month: "short" }, // "Lundi 12 janv."
|
||||
nowIndicator: true,
|
||||
slotDuration: '00:30:00',
|
||||
dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'short' },
|
||||
events: mapEvents(props.interventions),
|
||||
|
||||
eventClick: (info) => {
|
||||
const originalEvent = info.event.extendedProps.originalData;
|
||||
emit("edit", originalEvent);
|
||||
info.jsEvent.stopPropagation();
|
||||
const orig = info.event.extendedProps.originalData;
|
||||
const color = info.event.extendedProps.color;
|
||||
showPopover(info.jsEvent, orig, color);
|
||||
},
|
||||
|
||||
dateClick: (info) => {
|
||||
// timeGrid view dateClick gives date with time
|
||||
emit("cell-click", { date: info.date });
|
||||
closePopover();
|
||||
emit('cell-click', { date: info.date });
|
||||
},
|
||||
// Styling customization via class names injection if needed
|
||||
eventClassNames: (arg) => {
|
||||
return ["shadow-sm", "border-0"];
|
||||
|
||||
eventContent: (arg) => {
|
||||
const data = arg.event.extendedProps.originalData;
|
||||
const color = arg.event.extendedProps.color;
|
||||
const icon = typeIcons[data?.type] || 'fas fa-briefcase-medical';
|
||||
const fmt = (d) => new Date(d).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
return {
|
||||
html: `
|
||||
<div class="fc-event-custom">
|
||||
<div class="fce-header">
|
||||
<i class="${icon} fce-icon"></i>
|
||||
<span class="fce-time">${fmt(arg.event.start)}</span>
|
||||
</div>
|
||||
<div class="fce-title">${arg.event.title}</div>
|
||||
${data?.client ? `<div class="fce-sub">${data.client}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
};
|
||||
},
|
||||
|
||||
viewDidMount: () => {
|
||||
currentDate.value = calendar.getDate();
|
||||
},
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
currentDate.value = calendar.getDate();
|
||||
};
|
||||
|
||||
const mapEvents = (interventions) => {
|
||||
return interventions.map((i) => {
|
||||
// Map props.interventions structure to FullCalendar event object
|
||||
// Assuming intervention has: id, date (ISO string), title (or type), status, color
|
||||
const typeColors = {
|
||||
Soin: "#3b82f6",
|
||||
Transport: "#10b981",
|
||||
"Mise en bière": "#f59e0b",
|
||||
Cérémonie: "#8b5cf6",
|
||||
};
|
||||
onMounted(() => initCalendar());
|
||||
onUnmounted(() => { if (calendar) calendar.destroy(); });
|
||||
|
||||
// Default duration 1 hour if not specified
|
||||
const start = new Date(i.date);
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
id: i.id,
|
||||
title: i.deceased ? `${i.type} - ${i.deceased}` : i.type,
|
||||
start: i.date,
|
||||
end: i.end || end, // Use provided end or default
|
||||
backgroundColor: typeColors[i.type] || "#6b7280",
|
||||
borderColor: typeColors[i.type] || "#6b7280",
|
||||
textColor: "#ffffff",
|
||||
extendedProps: {
|
||||
originalData: i,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeCalendar();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(newDate) => {
|
||||
if (calendar) {
|
||||
calendar.gotoDate(newDate);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.interventions,
|
||||
(newInterventions) => {
|
||||
watch(() => props.startDate, (d) => { if (calendar) { calendar.gotoDate(d); currentDate.value = d; } });
|
||||
watch(() => props.interventions, (v) => {
|
||||
if (calendar) {
|
||||
calendar.removeAllEvents();
|
||||
calendar.addEventSource(mapEvents(newInterventions));
|
||||
calendar.addEventSource(mapEvents(v));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
}, { deep: true });
|
||||
|
||||
// Force label recompute when currentDate changes
|
||||
watch(currentDate, () => {}); // just triggers computed re-eval
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-grid-container {
|
||||
min-height: 600px;
|
||||
/* ─── Root ─── */
|
||||
.calendar-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
/* FullCalendar Custom Overrides to match Soft UI */
|
||||
:deep(.fc-col-header-cell) {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
/* ─── Header ─── */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
background: #fff;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.week-icon {
|
||||
color: #2d4a6e;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.week-range {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-btn:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 9px;
|
||||
border: 1.5px solid #2d4a6e;
|
||||
background: #2d4a6e;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #67748e;
|
||||
padding-bottom: 10px;
|
||||
text-transform: capitalize;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.today-btn:hover {
|
||||
background: #1a2e4a;
|
||||
border-color: #1a2e4a;
|
||||
}
|
||||
|
||||
:deep(.fc-timegrid-slot) {
|
||||
height: 3rem; /* Taller slots */
|
||||
/* ─── Calendar Body ─── */
|
||||
.calendar-body {
|
||||
flex: 1;
|
||||
padding: 0 4px 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.fc-timegrid-slot-label) {
|
||||
font-size: 0.75rem;
|
||||
color: #8392ab;
|
||||
font-weight: 600;
|
||||
.fc-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ─── FullCalendar Overrides ─── */
|
||||
:deep(.fc) {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:deep(.fc-scrollgrid) {
|
||||
border: none;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.fc td),
|
||||
:deep(.fc th) {
|
||||
border-color: #e9ecef;
|
||||
border-color: #f0f2f5 !important;
|
||||
}
|
||||
|
||||
/* Today highlight */
|
||||
:deep(.fc-col-header) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:deep(.fc-col-header-cell) {
|
||||
padding: 10px 4px !important;
|
||||
border-bottom: 2px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
text-transform: capitalize;
|
||||
text-decoration: none !important;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Today column highlight */
|
||||
:deep(.fc-day-today) {
|
||||
background-color: rgba(233, 236, 239, 0.3) !important;
|
||||
background: #fafbff !important;
|
||||
}
|
||||
|
||||
:deep(.fc-day-today .fc-col-header-cell-cushion) {
|
||||
color: #2d4a6e;
|
||||
}
|
||||
|
||||
:deep(.fc-day-today .fc-col-header-cell) {
|
||||
border-bottom-color: #2d4a6e !important;
|
||||
}
|
||||
|
||||
/* Time labels */
|
||||
:deep(.fc-timegrid-slot-label-cushion) {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
:deep(.fc-timegrid-slot) {
|
||||
height: 3rem !important;
|
||||
}
|
||||
|
||||
:deep(.fc-timegrid-slot-minor) {
|
||||
border-top-style: dashed !important;
|
||||
border-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* Now indicator */
|
||||
:deep(.fc-timegrid-now-indicator-line) {
|
||||
border-color: #ef4444 !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
:deep(.fc-timegrid-now-indicator-arrow) {
|
||||
border-top-color: #ef4444 !important;
|
||||
border-bottom-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Events */
|
||||
:deep(.fc-event) {
|
||||
border-radius: 7px !important;
|
||||
border-width: 0 0 0 3px !important;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s !important;
|
||||
padding: 0 !important;
|
||||
margin: 1px 2px !important;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.fc-event:hover) {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* Custom event content */
|
||||
:deep(.fc-event-custom) {
|
||||
padding: 5px 7px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.fce-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
:deep(.fce-icon) {
|
||||
font-size: 0.62rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
:deep(.fce-time) {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:deep(.fce-title) {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
:deep(.fce-sub) {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ─── Popover ─── */
|
||||
.event-popover {
|
||||
position: absolute;
|
||||
width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.08);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.popover-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 199;
|
||||
}
|
||||
|
||||
.popover-strip {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popover-body {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popover-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.popover-type {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.popover-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.popover-close:hover { color: #374151; }
|
||||
|
||||
.popover-deceased {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.popover-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.77rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.popover-meta i {
|
||||
font-size: 0.7rem;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.popover-status {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.popover-edit-btn {
|
||||
background: #1a2e4a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.popover-edit-btn:hover { background: #2d4a6e; }
|
||||
|
||||
/* ─── Popover animation ─── */
|
||||
.popover-fade-enter-active,
|
||||
.popover-fade-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.popover-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.94) translateY(-6px);
|
||||
}
|
||||
.popover-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
@ -1,7 +1,43 @@
|
||||
<template>
|
||||
<div class="card mt-4">
|
||||
<div class="table-responsive">
|
||||
<table id="client-group-list" class="table table-flush">
|
||||
<div class="table-container card mt-4">
|
||||
<div class="card-body pb-0">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive px-3 pb-3">
|
||||
<table class="table table-flush mb-0">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
@ -11,8 +47,22 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="4" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="data.length === 0">
|
||||
<td colspan="4" class="text-center py-4 text-secondary text-sm">
|
||||
Aucun groupe trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<template v-else>
|
||||
<tr v-for="group in data" :key="group.id">
|
||||
<!-- Name -->
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-checkbox class="me-2" />
|
||||
@ -22,67 +72,125 @@
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Description -->
|
||||
<td class="text-sm">
|
||||
<span class="text-secondary">
|
||||
{{ group.description || "Aucune description" }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Created At -->
|
||||
<td class="text-sm font-weight-bold">
|
||||
<span class="my-2 text-xs">{{
|
||||
formatDate(group.created_at)
|
||||
}}</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-xs font-weight-bold">
|
||||
<div class="d-flex align-items-center">
|
||||
<button
|
||||
class="btn btn-link text-secondary mb-0 px-2"
|
||||
:data-id="group.id"
|
||||
data-action="view"
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<soft-button
|
||||
color="info"
|
||||
variant="outline"
|
||||
title="Voir le groupe"
|
||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||
@click="emit('view', group.id)"
|
||||
>
|
||||
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link text-secondary mb-0 px-2"
|
||||
:data-id="group.id"
|
||||
data-action="edit"
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
color="warning"
|
||||
variant="outline"
|
||||
title="Modifier le groupe"
|
||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||
@click="emit('edit', group.id)"
|
||||
>
|
||||
<i class="fas fa-edit text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link text-danger mb-0 px-2"
|
||||
:data-id="group.id"
|
||||
data-action="delete"
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
|
||||
<soft-button
|
||||
color="danger"
|
||||
variant="outline"
|
||||
title="Supprimer le groupe"
|
||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||
@click="emit('delete', group.id)"
|
||||
>
|
||||
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading && data.length > 0"
|
||||
class="d-flex justify-content-between align-items-center mt-1 px-3 pb-3"
|
||||
>
|
||||
<div class="text-xs text-secondary font-weight-bold">
|
||||
Affichage de {{ from }} à {{ to }} sur {{ pagination.total }} groupes
|
||||
</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-${page}`"
|
||||
class="page-item"
|
||||
:class="{
|
||||
active: pagination.current_page === page,
|
||||
disabled: 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
watch,
|
||||
onUnmounted,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
} from "vue";
|
||||
import { DataTable } from "simple-datatables";
|
||||
import { computed, defineProps, defineEmits } from "vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@ -93,11 +201,25 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["view", "edit", "delete"]);
|
||||
|
||||
const dataTableInstance = ref(null);
|
||||
const emit = defineEmits([
|
||||
"view",
|
||||
"edit",
|
||||
"delete",
|
||||
"page-change",
|
||||
"per-page-change",
|
||||
"search-change",
|
||||
]);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "-";
|
||||
@ -109,60 +231,63 @@ const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString("fr-FR", options);
|
||||
};
|
||||
|
||||
const initializeDataTable = () => {
|
||||
const table = document.getElementById("client-group-list");
|
||||
if (!table) return;
|
||||
const displayedPages = computed(() => {
|
||||
const total = props.pagination.last_page || 1;
|
||||
const current = props.pagination.current_page || 1;
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
|
||||
if (dataTableInstance.value) {
|
||||
dataTableInstance.value.destroy();
|
||||
for (
|
||||
let i = Math.max(2, current - delta);
|
||||
i <= Math.min(total - 1, current + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
dataTableInstance.value = new DataTable(table, {
|
||||
searchable: true,
|
||||
fixedHeight: false,
|
||||
perPage: 10,
|
||||
});
|
||||
|
||||
// Event delegation for action buttons
|
||||
table.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("button");
|
||||
if (!btn) return;
|
||||
|
||||
const id = btn.getAttribute("data-id");
|
||||
const action = btn.getAttribute("data-action");
|
||||
|
||||
if (id && action) {
|
||||
if (action === "view") {
|
||||
emit("view", parseInt(id));
|
||||
} else if (action === "edit") {
|
||||
emit("edit", parseInt(id));
|
||||
} else if (action === "delete") {
|
||||
emit("delete", parseInt(id));
|
||||
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] !== "...")
|
||||
);
|
||||
});
|
||||
|
||||
const from = computed(() => {
|
||||
if (!props.pagination.total || props.data.length === 0) return 0;
|
||||
return (props.pagination.current_page - 1) * props.pagination.per_page + 1;
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (!props.pagination.total || props.data.length === 0) return 0;
|
||||
return Math.min(
|
||||
props.pagination.current_page * props.pagination.per_page,
|
||||
props.pagination.total
|
||||
);
|
||||
});
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page !== "..." && page >= 1 && page <= props.pagination.last_page) {
|
||||
emit("page-change", page);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.data && props.data.length > 0) {
|
||||
initializeDataTable();
|
||||
}
|
||||
});
|
||||
const onPerPageChange = (event) => {
|
||||
const newPerPage = parseInt(event.target.value, 10);
|
||||
emit("per-page-change", newPerPage);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newData) => {
|
||||
if (newData && newData.length > 0) {
|
||||
setTimeout(() => {
|
||||
initializeDataTable();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dataTableInstance.value) {
|
||||
dataTableInstance.value.destroy();
|
||||
}
|
||||
});
|
||||
const onSearch = debounce((event) => {
|
||||
emit("search-change", event.target.value);
|
||||
}, 300);
|
||||
</script>
|
||||
|
||||
@ -46,16 +46,24 @@ import { ref, onMounted, computed } from "vue";
|
||||
import PlanningPresentation from "@/components/Organism/Planning/PlanningPresentation.vue";
|
||||
import PlanningNewRequestModal from "@/components/Organism/Planning/PlanningNewRequestModal.vue";
|
||||
import InterventionMultiStepModal from "@/components/Organism/Agenda/InterventionMultiStepModal.vue";
|
||||
import { useInterventionStore } from "@/stores/interventionStore";
|
||||
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
||||
import { useNotificationStore } from "@/stores/notification";
|
||||
|
||||
// State
|
||||
const interventions = ref([]);
|
||||
const collaborators = ref([]);
|
||||
const monthBuckets = ref({});
|
||||
const monthVersions = ref({});
|
||||
const monthRequestIds = ref({});
|
||||
const localPlanningItems = ref([]);
|
||||
const currentDate = ref(new Date());
|
||||
const activeView = ref("grille");
|
||||
const showNewRequestModal = ref(false);
|
||||
const creationType = ref("");
|
||||
const interventionModalRef = ref(null);
|
||||
const initialInterventionDate = ref("");
|
||||
const interventionStore = useInterventionStore();
|
||||
const thanatopractitionerStore = useThanatopractitionerStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const leaveForm = ref({
|
||||
employee: "",
|
||||
@ -78,88 +86,136 @@ const creationTypeTitle = computed(() => {
|
||||
return "Nouvelle demande";
|
||||
});
|
||||
|
||||
const practitioners = computed(() =>
|
||||
collaborators.value.map((collab) => {
|
||||
const [firstName = collab.name, ...rest] = collab.name.split(" ");
|
||||
return {
|
||||
id: collab.id,
|
||||
employee: {
|
||||
first_name: firstName,
|
||||
last_name: rest.join(" "),
|
||||
},
|
||||
};
|
||||
})
|
||||
const practitioners = computed(
|
||||
() => thanatopractitionerStore.thanatopractitioners || []
|
||||
);
|
||||
|
||||
const collaborators = computed(() =>
|
||||
practitioners.value.map((p) => ({
|
||||
id: p.id,
|
||||
name: `${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
||||
`Collaborateur #${p.id}`,
|
||||
}))
|
||||
);
|
||||
|
||||
const backendToUiStatus = {
|
||||
demande: "En attente",
|
||||
planifie: "Confirmé",
|
||||
en_cours: "En cours",
|
||||
terminee: "Terminé",
|
||||
facturee: "Terminé",
|
||||
annule: "Annulé",
|
||||
};
|
||||
|
||||
const uiToBackendStatus = {
|
||||
"En attente": "demande",
|
||||
Confirmé: "planifie",
|
||||
"En cours": "en_cours",
|
||||
Terminé: "terminee",
|
||||
Annulé: "annule",
|
||||
};
|
||||
|
||||
const mapInterventionToPlanning = (item) => {
|
||||
const practitioner =
|
||||
item.principal_practitioner || item.assigned_practitioner || item.practitioners?.[0];
|
||||
const collaborator = `${practitioner?.employee?.first_name || ""} ${
|
||||
practitioner?.employee?.last_name || ""
|
||||
}`.trim();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
date: item.scheduled_at || item.date || item.created_at,
|
||||
end: item.estimated_end_at || item.end,
|
||||
type:
|
||||
item.product?.nom || item.product_name || item.type_label || item.type || "Intervention",
|
||||
deceased:
|
||||
item.deceased_name ||
|
||||
`${item.deceased?.first_name || ""} ${item.deceased?.last_name || ""}`.trim() ||
|
||||
"Non spécifié",
|
||||
client: item.client_name || item.client?.name || "-",
|
||||
collaborator: collaborator || "-",
|
||||
status: backendToUiStatus[item.status] || item.status || "En attente",
|
||||
rawStatus: item.status,
|
||||
};
|
||||
};
|
||||
|
||||
const weekRange = computed(() => {
|
||||
const start = getMonday(currentDate.value);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const apiInterventions = computed(() =>
|
||||
Object.values(monthBuckets.value)
|
||||
.flat()
|
||||
.map(mapInterventionToPlanning)
|
||||
);
|
||||
|
||||
const interventions = computed(() => {
|
||||
const { start, end } = weekRange.value;
|
||||
return [...apiInterventions.value, ...localPlanningItems.value]
|
||||
.filter((i) => {
|
||||
if (!i.date) return false;
|
||||
const d = new Date(i.date);
|
||||
return d >= start && d <= end;
|
||||
})
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
await Promise.all([fetchData(), thanatopractitionerStore.fetchThanatopractitioners()]);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const fetchData = async () => {
|
||||
// Mock data for demonstration
|
||||
collaborators.value = [
|
||||
{ id: 1, name: "Jean Dupont" },
|
||||
{ id: 2, name: "Marie Curie" },
|
||||
{ id: 3, name: "Lucas Martin" },
|
||||
];
|
||||
const getMonthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// Generate some random interventions around current date
|
||||
const baseDate = new Date(currentDate.value);
|
||||
const startOfWeek = getMonday(baseDate);
|
||||
const bumpMonthVersion = (key) => {
|
||||
monthVersions.value[key] = (monthVersions.value[key] || 0) + 1;
|
||||
};
|
||||
|
||||
interventions.value = [
|
||||
{
|
||||
id: 101,
|
||||
date: addDays(startOfWeek, 0, 9, 30), // Monday 9:30
|
||||
type: "Soin",
|
||||
deceased: "M. Martin",
|
||||
client: "Pompes Funèbres Générales",
|
||||
collaborator: "Jean Dupont",
|
||||
status: "Confirmé",
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
date: addDays(startOfWeek, 1, 14, 0), // Tuesday 14:00
|
||||
type: "Transport",
|
||||
deceased: "Mme. Dubois",
|
||||
client: "Roc Eclerc",
|
||||
collaborator: "Marie Curie",
|
||||
status: "En cours",
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
date: addDays(startOfWeek, 2, 10, 0), // Wednesday 10:00
|
||||
type: "Mise en bière",
|
||||
deceased: "M. Lefebvre",
|
||||
client: "PF Locales",
|
||||
collaborator: "Lucas Martin",
|
||||
status: "En attente",
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
date: addDays(startOfWeek, 3, 16, 30), // Thursday 16:30
|
||||
type: "Cérémonie",
|
||||
deceased: "Mme. Petit",
|
||||
client: "Famille Petit",
|
||||
collaborator: "Jean Dupont",
|
||||
status: "Terminé",
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
date: addDays(startOfWeek, 4, 11, 0), // Friday 11:00
|
||||
type: "Soin",
|
||||
deceased: "Inconnu",
|
||||
client: "Police",
|
||||
collaborator: "Marie Curie",
|
||||
status: "Annulé",
|
||||
},
|
||||
];
|
||||
const setMonthBucket = (key, items) => {
|
||||
bumpMonthVersion(key);
|
||||
monthBuckets.value[key] = items;
|
||||
};
|
||||
|
||||
const fetchMonth = async (date, force = false) => {
|
||||
const key = getMonthKey(date);
|
||||
if (!force && monthBuckets.value[key]) return;
|
||||
|
||||
const requestId = (monthRequestIds.value[key] || 0) + 1;
|
||||
monthRequestIds.value[key] = requestId;
|
||||
const startedVersion = monthVersions.value[key] || 0;
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const response = await interventionStore.fetchInterventionsByMonth(year, month);
|
||||
|
||||
// Guard against stale async responses overriding newer local/server updates
|
||||
if (monthRequestIds.value[key] !== requestId) return;
|
||||
if ((monthVersions.value[key] || 0) !== startedVersion) return;
|
||||
|
||||
setMonthBucket(key, Array.isArray(response?.data) ? response.data : []);
|
||||
};
|
||||
|
||||
const fetchData = async (force = false) => {
|
||||
try {
|
||||
const { start, end } = weekRange.value;
|
||||
await Promise.all([fetchMonth(start, force), fetchMonth(end, force)]);
|
||||
} catch (error) {
|
||||
console.error("Error loading planning interventions:", error);
|
||||
notificationStore.error(
|
||||
"Erreur",
|
||||
"Impossible de charger les interventions du planning"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await fetchData();
|
||||
await fetchData(true);
|
||||
};
|
||||
|
||||
const handleNewRequest = () => {
|
||||
@ -190,28 +246,20 @@ const resetType = () => {
|
||||
creationType.value = "";
|
||||
};
|
||||
|
||||
const handleInterventionSubmit = (formData) => {
|
||||
const practitioner = practitioners.value.find(
|
||||
(p) => p.id?.toString() === formData.assigned_practitioner_id?.toString()
|
||||
);
|
||||
|
||||
const collaboratorName = practitioner
|
||||
? `${practitioner.employee?.first_name || ""} ${
|
||||
practitioner.employee?.last_name || ""
|
||||
}`.trim()
|
||||
: collaborators.value[0]?.name || "-";
|
||||
|
||||
interventions.value.unshift({
|
||||
id: Date.now(),
|
||||
date: formData.scheduled_at
|
||||
? new Date(formData.scheduled_at).toISOString()
|
||||
: new Date().toISOString(),
|
||||
type: formData.product_name || formData.type || "Intervention",
|
||||
deceased: formData.deceased_name || "Défunt",
|
||||
client: formData.client_name || "Client",
|
||||
collaborator: collaboratorName,
|
||||
status: "En attente",
|
||||
});
|
||||
const handleInterventionSubmit = async (formData) => {
|
||||
try {
|
||||
await interventionStore.createInterventionWithAllData(formData);
|
||||
interventionModalRef.value?.hide();
|
||||
await fetchData(true);
|
||||
notificationStore.created("Intervention");
|
||||
} catch (error) {
|
||||
console.error("Error saving intervention:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
"Erreur lors de l'enregistrement de l'intervention";
|
||||
notificationStore.error("Erreur", errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const submitLeave = () => {
|
||||
@ -220,7 +268,7 @@ const submitLeave = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
interventions.value.unshift({
|
||||
localPlanningItems.value.unshift({
|
||||
id: Date.now() + 1,
|
||||
date: new Date(`${leaveForm.value.startDate}T09:00`).toISOString(),
|
||||
type: "Congé",
|
||||
@ -252,12 +300,15 @@ const submitEvent = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
interventions.value.unshift({
|
||||
localPlanningItems.value.unshift({
|
||||
id: Date.now() + 2,
|
||||
date: new Date(eventForm.value.date).toISOString(),
|
||||
type: "Événement",
|
||||
deceased: eventForm.value.title,
|
||||
client: eventForm.value.location || eventForm.value.description || "Événement interne",
|
||||
client:
|
||||
eventForm.value.location ||
|
||||
eventForm.value.description ||
|
||||
"Événement interne",
|
||||
collaborator: "Équipe",
|
||||
status: "Confirmé",
|
||||
});
|
||||
@ -293,16 +344,25 @@ const handlePrevWeek = () => {
|
||||
fetchData(); // Refresh data for new week
|
||||
};
|
||||
|
||||
const handleUpdateStatus = (payload) => {
|
||||
const intervention = interventions.value.find(
|
||||
(i) => i.id.toString() === payload.id
|
||||
);
|
||||
if (intervention) {
|
||||
intervention.status = payload.status;
|
||||
// In a real app, we would make an API call here to persist the change
|
||||
console.log(
|
||||
`Updated status of intervention ${payload.id} to ${payload.status}`
|
||||
);
|
||||
const handleUpdateStatus = async (payload) => {
|
||||
const interventionId = Number(payload.id);
|
||||
const status = uiToBackendStatus[payload.status] || payload.status;
|
||||
|
||||
try {
|
||||
const updated = await interventionStore.updateInterventionStatus(interventionId, status);
|
||||
const monthKey = getMonthKey(new Date(updated.scheduled_at || updated.date || currentDate.value));
|
||||
const monthItems = monthBuckets.value[monthKey] || [];
|
||||
const idx = monthItems.findIndex((i) => i.id === interventionId);
|
||||
if (idx !== -1) {
|
||||
const nextItems = [...monthItems];
|
||||
nextItems[idx] = updated;
|
||||
setMonthBucket(monthKey, nextItems);
|
||||
} else {
|
||||
await fetchData(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating intervention status:", error);
|
||||
notificationStore.error("Erreur", "Échec de la mise à jour du statut");
|
||||
}
|
||||
};
|
||||
|
||||
@ -310,21 +370,14 @@ const handleNextWeek = () => {
|
||||
const newDate = new Date(currentDate.value);
|
||||
newDate.setDate(newDate.getDate() + 7);
|
||||
currentDate.value = newDate;
|
||||
fetchData(); // Refresh data for new week
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Utilities
|
||||
function getMonday(d) {
|
||||
d = new Date(d);
|
||||
var day = d.getDay(),
|
||||
diff = d.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
return new Date(d.setDate(diff));
|
||||
}
|
||||
|
||||
function addDays(date, days, hours, minutes) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
result.setHours(hours, minutes, 0, 0);
|
||||
return result.toISOString();
|
||||
}
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user