gigafibre-fsm/apps/ops/src/components/shared/ReportScaffold.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
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>
2026-07-02 08:49:35 -04:00

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>