FIX: Creation demande, client on clique show

This commit is contained in:
nyavokevin 2026-03-05 17:00:32 +03:00
parent 11750a3ffc
commit 8074ac4f48
22 changed files with 2982 additions and 1442 deletions

View File

@ -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 lassignation des clients au groupe.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 typeColors = {
'Soin': '#3b82f6',
'Transport': '#10b981',
'Mise en bière': '#f59e0b',
'Cérémonie': '#8b5cf6',
};
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 = {
'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>
`;
const typeIcons = {
'Soin': 'fas fa-heartbeat',
'Transport': 'fas fa-car',
'Mise en bière': 'fas fa-box',
'Cérémonie': 'fas fa-dove',
};
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 = "";
const getTypeColor = (type) => typeColors[type] || '#6b7280';
const getTypeIcon = (type) => typeIcons[type] || 'fas fa-briefcase-medical';
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 });
}
}
});
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() : '?';
// Apply custom styling adjustments after render
applyCustomStyles();
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);
};
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().
}
});
// 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());
};
onMounted(() => {
// Give DOM a moment
setTimeout(() => {
initKanban();
}, 100);
});
const onDragEnd = () => {
draggingId.value = null;
draggingItem.value = null;
dragOverCol.value = null;
document.querySelectorAll('.cards-zone').forEach(z => z.classList.remove('drag-over'));
};
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 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;
}
</style>
/* ─── 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>

View File

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

View File

@ -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.startDate, (d) => { if (calendar) { calendar.gotoDate(d); currentDate.value = d; } });
watch(() => props.interventions, (v) => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(mapEvents(v));
}
);
}, { deep: true });
watch(
() => props.interventions,
(newInterventions) => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(mapEvents(newInterventions));
}
},
{ 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-day-today) {
background-color: rgba(233, 236, 239, 0.3) !important;
:deep(.fc-col-header) {
background: #f8fafc;
}
</style>
: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: #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>

View File

@ -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,78 +47,150 @@
</tr>
</thead>
<tbody>
<tr v-for="group in data" :key="group.id">
<!-- Name -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-sm font-weight-bold ms-2 mb-0">
{{ group.name }}
</p>
</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"
title="Voir le groupe"
>
<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"
title="Modifier le groupe"
>
<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"
title="Supprimer le groupe"
>
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
</button>
<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">
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-sm font-weight-bold ms-2 mb-0">
{{ group.name }}
</p>
</div>
</td>
<td class="text-sm">
<span class="text-secondary">
{{ group.description || "Aucune description" }}
</span>
</td>
<td class="text-sm font-weight-bold">
<span class="my-2 text-xs">{{
formatDate(group.created_at)
}}</span>
</td>
<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" 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" 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" 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,
});
if (current - delta > 2) {
range.unshift("...");
}
if (current + delta < total - 1) {
range.push("...");
}
// Event delegation for action buttons
table.addEventListener("click", (event) => {
const btn = event.target.closest("button");
if (!btn) return;
range.unshift(1);
if (total > 1) {
range.push(total);
}
const id = btn.getAttribute("data-id");
const action = btn.getAttribute("data-action");
return range.filter(
(val, index, self) =>
val !== "..." || (val === "..." && self[index - 1] !== "...")
);
});
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));
}
}
});
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>

View File

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