Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.7 KiB
Vue
80 lines
3.7 KiB
Vue
<template>
|
|
<!-- Échafaudage commun des pages de rapports : en-tête + tuiles stats + graphique + états (chargement/erreur/vide) + tableau responsive.
|
|
La page fournit : filtres (#filters), cartes (:cards), colonnes (:columns), lignes (:rows) et un fetch qui pilote :loading/:error/:has-run.
|
|
Les slots body-cell-* / #item sont transmis au DataTable. Voir composables/useCsvExport pour @export. -->
|
|
<q-page padding>
|
|
<PageHeader :title="title" :back="back">
|
|
<template #actions>
|
|
<q-input v-if="searchable" v-model="search" dense outlined placeholder="Filtrer…" clearable class="rs-search">
|
|
<template #prepend><q-icon name="search" /></template>
|
|
</q-input>
|
|
<slot name="actions" />
|
|
<q-btn v-if="exportable && rows && rows.length" flat dense no-caps icon="download" label="CSV" @click="$emit('export')">
|
|
<q-tooltip>Exporter en CSV</q-tooltip>
|
|
</q-btn>
|
|
</template>
|
|
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
|
</PageHeader>
|
|
|
|
<div v-if="cards && cards.length" class="row q-col-gutter-sm q-mb-md">
|
|
<div v-for="(c, i) in cards" :key="i" class="col-6 col-sm">
|
|
<StatCard :label="c.label" :value="c.value" :icon="c.icon" :color="c.color || 'primary'" :value-class="c.valueClass" :hint="c.hint" />
|
|
</div>
|
|
</div>
|
|
|
|
<slot name="chart" />
|
|
|
|
<TableSkeleton v-if="loading" :rows="8" :cols="(columns && columns.length) || 5" />
|
|
<div v-else-if="error" class="rs-state">
|
|
<q-icon name="error_outline" size="34px" color="negative" />
|
|
<div>{{ error }}</div>
|
|
<q-btn outline no-caps color="primary" label="Réessayer" @click="$emit('retry')" />
|
|
</div>
|
|
<EmptyState v-else-if="!rows || !rows.length"
|
|
:icon="hasRun ? 'inbox' : 'filter_alt'"
|
|
:label="hasRun ? empty : 'Choisissez une période, puis « Générer ».'" />
|
|
<DataTable v-else :rows="rows" :columns="columns" :row-key="rowKey" :pagination="pagination" :filter="searchable ? search : undefined">
|
|
<template v-for="name in tableSlotNames" #[name]="sp"><slot :name="name" v-bind="sp || {}" /></template>
|
|
</DataTable>
|
|
|
|
<!-- Contenu additionnel sous le tableau (ex. résumé de période) — la page gère sa propre visibilité. -->
|
|
<div v-if="$slots.below && !loading && !error"><slot name="below" /></div>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { useSlots, computed, ref } from 'vue'
|
|
import PageHeader from './PageHeader.vue'
|
|
import StatCard from './StatCard.vue'
|
|
import DataTable from './DataTable.vue'
|
|
import TableSkeleton from './TableSkeleton.vue'
|
|
import EmptyState from './EmptyState.vue'
|
|
|
|
defineProps({
|
|
title: { type: String, default: '' },
|
|
back: { type: [String, Object], default: '/rapports' },
|
|
loading: { type: Boolean, default: false },
|
|
error: { type: String, default: '' },
|
|
hasRun: { type: Boolean, default: true },
|
|
cards: { type: Array, default: () => [] },
|
|
columns: { type: Array, default: () => [] },
|
|
rows: { type: Array, default: () => [] },
|
|
rowKey: { type: String, default: 'name' },
|
|
pagination: { type: Object, default: () => ({ rowsPerPage: 50 }) },
|
|
empty: { type: String, default: 'Aucune donnée pour cette période.' },
|
|
exportable: { type: Boolean, default: true },
|
|
searchable: { type: Boolean, default: false },
|
|
})
|
|
defineEmits(['retry', 'export'])
|
|
|
|
const search = ref('')
|
|
const slots = useSlots()
|
|
const RESERVED = ['filters', 'chart', 'actions', 'below']
|
|
const tableSlotNames = computed(() => Object.keys(slots).filter(n => !RESERVED.includes(n)))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.rs-state { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px 20px; text-align: center; color: var(--ops-text-muted); }
|
|
.rs-search { width: 200px; max-width: 100%; }
|
|
</style>
|