mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Carte tendance réseau : nos magasins et une lecture en clair
- Phrase de lecture en tête : la tendance du réseau avec ses deux chiffres (qté par magasin, 4 derniers mois contre 4 premiers), puis notre situation (ventes 12 mois, stock, couverture, commandes en cours). - Nouvelle section « Nos magasins » : stock fin de mois, ventes 12 mois, rythme mensuel, couverture, dernière entrée et écart au magasin moyen du réseau, par magasin et pour l'ensemble. - Réseau : période affichée en clair, tuile CA réseau 12 mois, part du réseau dans la tuile des magasins vendeurs. - Modal sorti de heatmap-grid.tsx (network-monthly-modal.tsx). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
This commit is contained in:
2 files changed
+324
-80
No files matched your search
@@ -14,7 +14,7 @@ import {
|
||||
type FilterFn,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -26,8 +26,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal";
|
||||
import { NetworkMonthlyModal } from "@/features/grid/components/network-monthly-modal";
|
||||
import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
|
||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -36,18 +36,14 @@ import {
|
||||
getMonthsFromRows,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
fmtMonthShort,
|
||||
SITE_LABELS,
|
||||
getStoreConfig,
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
|
||||
@@ -554,78 +550,6 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
|
||||
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
|
||||
* jamais par la couleur seule. Les courbes, elles, portent des teintes
|
||||
* d'identité — sans quoi la même couleur voudrait dire deux choses.
|
||||
*/
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = tendanceDe(row);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
|
||||
const dernier = values.length - 1;
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const verdict = trend.nouveau
|
||||
? "Nouveau"
|
||||
: pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Réseau · 12 mois glissants, mois en cours exclu
|
||||
{trend.enRetard && labels.length > 0 && ` · jusqu'à ${fmtMonthShort(labels[labels.length - 1])}, le dernier mois n'est pas encore synchronisé`}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={trend.surQteParMagasin
|
||||
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
|
||||
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
|
||||
couleur={color}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && (
|
||||
<TuileStat
|
||||
label="Qté / magasin"
|
||||
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
|
||||
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
|
||||
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
|
||||
/>
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||
@@ -1507,7 +1431,14 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
|
||||
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||
</Dialog>
|
||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||
{networkModal !== null && (
|
||||
<NetworkMonthlyModal
|
||||
row={networkModal}
|
||||
trend={tendanceDe(networkModal)}
|
||||
mois={MONTHS_12}
|
||||
onClose={() => setNetworkModal(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
{/* Trous d'assortiment sur le haut du classement affiché */}
|
||||
<Dialog open={gapsOpen} onOpenChange={setGapsOpen}>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance.
|
||||
*
|
||||
* Trois étages, dans l'ordre où on se pose les questions :
|
||||
* 1. la lecture en une phrase (le produit progresse-t-il dans le réseau ?) ;
|
||||
* 2. le réseau : verdict, rythme par magasin, diffusion, puis les courbes ;
|
||||
* 3. nos magasins : stock, ventes, couverture, comparés au rythme du réseau.
|
||||
*
|
||||
* Sans le troisième étage, la carte disait comment le produit marche AILLEURS
|
||||
* sans dire où on en est CHEZ NOUS — il fallait rouvrir un autre détail pour
|
||||
* savoir s'il restait du stock.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit que dans la tuile de
|
||||
* verdict et la colonne « vs réseau », toujours accompagnée d'un signe ou d'un
|
||||
* libellé : un état ne se signale jamais par la couleur seule.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { TrendingUp, TrendingDown, Minus, Store, Warehouse } from "lucide-react";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import {
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
TREND_FLAT,
|
||||
TREND_WINDOW,
|
||||
NB_MAGASINS_RESEAU,
|
||||
type NetworkTrend,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { formatDate, formatMonthLabel, qlikMonthToFf } from "@/features/grid/lib/months";
|
||||
import { MAGASINS, LIBELLE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
import { fmtEur0 } from "@/lib/format";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
/** Une décimale sous 10, aucune au-delà : « 2,4 » mais « 37 ». */
|
||||
const fmtRythme = (v: number) => fmt(v, Math.abs(v) < 10 ? 1 : 0);
|
||||
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((t, v) => t + v, 0) / xs.length : 0);
|
||||
const pctTexte = (p: number) => `${p >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(p * 100)))} %`;
|
||||
|
||||
/** Mois de stock au rythme des ventes ; sans vente, le ratio n'existe pas et on le dit. */
|
||||
function couvertureTexte(stock: number, parMois: number): string {
|
||||
if (parMois <= 0) return stock > 0 ? "sans vente" : "—";
|
||||
const c = stock / parMois;
|
||||
if (c < 1) return "< 1 mois";
|
||||
if (c > 24) return "> 24 mois";
|
||||
return `${fmt(c, c < 10 ? 1 : 0)} mois`;
|
||||
}
|
||||
|
||||
interface LigneMagasin {
|
||||
cle: string;
|
||||
nom: string;
|
||||
stock: number;
|
||||
ventes: number;
|
||||
parMois: number;
|
||||
derniereEntree?: string;
|
||||
/** Écart à la moyenne réseau par magasin, `null` si non comparable. */
|
||||
vsReseau: number | null;
|
||||
total?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nos magasins, un par ligne, puis l'ensemble.
|
||||
*
|
||||
* « vs réseau » compare nos ventes mensuelles moyennes à celles d'un magasin
|
||||
* vendeur moyen du réseau sur la même période : c'est la seule assiette
|
||||
* commune (le réseau n'a pas de stock par magasin à nous offrir).
|
||||
*/
|
||||
function lignesMagasins(row: ProductRow, mois: string[], moyenneReseau: number | null): LigneMagasin[] {
|
||||
const dernier = mois[mois.length - 1];
|
||||
const ecart = (parMois: number) =>
|
||||
moyenneReseau != null && moyenneReseau > 0 ? parMois / moyenneReseau - 1 : null;
|
||||
|
||||
const lignes: LigneMagasin[] = MAGASINS.map((m) => {
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12mByStore?.[m.code]?.[k] ?? 0), 0);
|
||||
const parMois = ventes / mois.length;
|
||||
return {
|
||||
cle: m.code,
|
||||
nom: m.nom,
|
||||
stock: row.stock12mByStore?.[m.code]?.[dernier] ?? 0,
|
||||
ventes,
|
||||
parMois,
|
||||
derniereEntree: row.derniereLivraisonByStore?.[m.code],
|
||||
vsReseau: ventes > 0 ? ecart(parMois) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12m?.[k] ?? 0), 0);
|
||||
lignes.push({
|
||||
cle: "TOTAL",
|
||||
nom: LIBELLE_TOUS_MAGASINS,
|
||||
stock: row.stock12m?.[dernier] ?? lignes.reduce((t, l) => t + l.stock, 0),
|
||||
ventes,
|
||||
parMois: ventes / mois.length,
|
||||
derniereEntree: row.derniereLivraison,
|
||||
// Comparer deux magasins cumulés à UN magasin réseau n'aurait pas de sens.
|
||||
vsReseau: null,
|
||||
total: true,
|
||||
});
|
||||
return lignes;
|
||||
}
|
||||
|
||||
function TableauNosMagasins({ lignes, finPeriode }: { lignes: LigneMagasin[]; finPeriode: string }) {
|
||||
const entete = "px-2.5 py-2 font-semibold whitespace-nowrap";
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] tabular-nums" style={{ minWidth: 560 }} aria-label="Stock et ventes de nos magasins">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)" }}>
|
||||
<th className={`${entete} text-left`}>Magasin</th>
|
||||
<th className={`${entete} text-right`} title="Stock de fin de mois du dernier mois complet">Stock fin {finPeriode}</th>
|
||||
<th className={`${entete} text-right`}>Ventes 12 m</th>
|
||||
<th className={`${entete} text-right`}>Par mois</th>
|
||||
<th className={`${entete} text-right`} title="Combien de mois le stock tient au rythme des 12 derniers mois">Couverture</th>
|
||||
<th className={`${entete} text-right`} title="Nos ventes par mois comparées à celles d'un magasin vendeur moyen du réseau">vs réseau</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((l) => {
|
||||
const couleurEcart = l.vsReseau == null || Math.abs(l.vsReseau) <= TREND_FLAT
|
||||
? "var(--text-secondary)"
|
||||
: TREND_COLOR[l.vsReseau > 0 ? "up" : "down"];
|
||||
return (
|
||||
<tr
|
||||
key={l.cle}
|
||||
style={{
|
||||
borderTop: `1px solid ${l.total ? "var(--border-strong)" : "var(--border)"}`,
|
||||
background: l.total ? "var(--bg-elevated)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="px-2.5 py-2 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className={l.total ? "font-bold" : "font-semibold"}>{l.nom}</div>
|
||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{l.derniereEntree ? `dernière entrée ${formatDate(l.derniereEntree)}` : "aucune entrée enregistrée"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-bold" style={{ color: l.stock > 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.stock)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: l.ventes !== 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.ventes)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{l.ventes !== 0 ? fmtRythme(l.parMois) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{couvertureTexte(l.stock, l.parMois)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-semibold" style={{ color: couleurEcart }}>
|
||||
{l.vsReseau != null ? pctTexte(l.vsReseau) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titre de section : icône + intitulé + précision en clair. */
|
||||
function TitreSection({ icone: Icone, titre, detail }: {
|
||||
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
titre: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<Icone className="w-4 h-4 shrink-0 self-center" style={{ color: "var(--text-muted)" }} />
|
||||
<h3 className="text-[14px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
|
||||
{detail && <span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La tendance dite en une phrase, avec ses deux chiffres : on la lit avant
|
||||
* d'avoir à interpréter les tuiles et les courbes.
|
||||
*/
|
||||
function phraseReseau(trend: NetworkTrend): string {
|
||||
if (trend.nouveau) {
|
||||
return `Dans le réseau, le produit est nouveau : aucune vente sur les ${TREND_WINDOW} premiers mois, des ventes sur les ${TREND_WINDOW} derniers.`;
|
||||
}
|
||||
const serie = trend.perStore ?? trend.values;
|
||||
const n = serie.length;
|
||||
const debut = moyenne(serie.slice(0, TREND_WINDOW));
|
||||
const fin = moyenne(serie.slice(n - TREND_WINDOW));
|
||||
const unite = trend.perStore ? "par magasin vendeur et par mois" : "par mois dans tout le réseau";
|
||||
const sens = trend.direction === "up" ? "progresse" : trend.direction === "down" ? "recule" : "est stable";
|
||||
return `Dans le réseau, le produit ${sens} : ${fmtRythme(fin)} unités ${unite} sur les ${TREND_WINDOW} derniers mois, contre ${fmtRythme(debut)} sur les ${TREND_WINDOW} premiers.`;
|
||||
}
|
||||
|
||||
export function NetworkMonthlyModal({ row, trend, mois, onClose }: {
|
||||
row: ProductRow;
|
||||
trend: NetworkTrend;
|
||||
/** Fenêtre des ventes FF de la Grille (clés « YYYYMM »). */
|
||||
mois: string[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const couleur = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
const dernier = values.length - 1;
|
||||
|
||||
const verdict = trend.nouveau ? "Nouveau" : pct != null ? pctTexte(pct) : "—";
|
||||
const moyenneReseau = trend.perStore ? moyenne(trend.perStore) : null;
|
||||
const magasinsDernierMois = magasins ? magasins.values[dernier] : (row.nbMagasinsReseau ?? 0);
|
||||
|
||||
const lignes = lignesMagasins(row, mois, moyenneReseau);
|
||||
const finPeriode = mois.length ? formatMonthLabel(mois[mois.length - 1]) : "";
|
||||
const nous = lignes[lignes.length - 1];
|
||||
|
||||
const periodeReseau = labels.length
|
||||
? `${formatMonthLabel(qlikMonthToFf(labels[0]))} → ${formatMonthLabel(qlikMonthToFf(labels[labels.length - 1]))}`
|
||||
: "12 mois glissants";
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
// Même gabarit que le détail 12 mois : grille à une colonne, sinon le
|
||||
// tableau le plus large dimensionne la fenêtre et déborde.
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-3xl grid-cols-1 max-h-[88vh] overflow-y-auto"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
onInteractOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
{row.codein}
|
||||
{row.reference ? ` · réf. ${row.reference}` : ""}
|
||||
{row.codeCentrale ? ` · centrale ${row.codeCentrale}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Lecture en clair : réseau, puis nous. */}
|
||||
<div className="rounded-xl px-4 py-3 space-y-1 text-[14px] leading-relaxed" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{trend.hasData
|
||||
? <p>{phraseReseau(trend)}</p>
|
||||
: <p style={{ color: "var(--text-muted)" }}>Pas encore de détail mensuel réseau pour ce produit : relancez une synchronisation Qlik.</p>}
|
||||
<p style={{ color: "var(--text-secondary)" }}>
|
||||
Chez nous : {fmt(nous.ventes)} unités vendues en 12 mois, {fmt(nous.stock)} en stock fin {finPeriode}
|
||||
{nous.ventes > 0 ? ` (${couvertureTexte(nous.stock, nous.parMois)} de couverture)` : ""}
|
||||
{row.commandesEnCours ? `, ${fmt(row.commandesEnCours)} en commande` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trend.hasData && (
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={TrendingUp}
|
||||
titre="Réseau"
|
||||
detail={`${NB_MAGASINS_RESEAU} magasins · ${periodeReseau}, mois en cours exclu`}
|
||||
/>
|
||||
{trend.enRetard && (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
Le dernier mois n'est pas encore synchronisé depuis Qlik : la période s'arrête un mois plus tôt.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={`${trendLabel(pct, trend.nouveau)} · ${trend.surQteParMagasin ? "sur la qté par magasin" : "sur les volumes"}`}
|
||||
couleur={couleur}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && moyenneReseau != null && (
|
||||
<TuileStat
|
||||
label="Qté / magasin / mois"
|
||||
valeur={fmtRythme(trend.perStore[dernier])}
|
||||
indice={`dernier mois · ${fmtRythme(moyenneReseau)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={fmt(magasinsDernierMois)}
|
||||
indice={`dernier mois · ${fmt((magasinsDernierMois / NB_MAGASINS_RESEAU) * 100)} % du réseau`}
|
||||
/>
|
||||
{row.caReseau != null && row.caReseau > 0 && (
|
||||
<TuileStat
|
||||
label="CA réseau 12 m"
|
||||
valeur={fmtEur0(row.caReseau)}
|
||||
indice={row.margePctReseau ? `marge ${fmt(row.margePctReseau * 100, 1)} %` : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={Store}
|
||||
titre="Nos magasins"
|
||||
detail={`stock et ventes sur nos 12 derniers mois${moyenneReseau != null ? ` · réseau : ${fmtRythme(moyenneReseau)} / magasin / mois` : ""}`}
|
||||
/>
|
||||
<TableauNosMagasins lignes={lignes} finPeriode={finPeriode} />
|
||||
{row.stockActuel != null && (
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Warehouse className="w-3.5 h-3.5 shrink-0" />
|
||||
Stock disponible aujourd'hui, tous magasins : <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{fmt(row.stockActuel)}</span>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user