mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
feat(produits): page de recherche produit + fiche 360° local/réseau
Nouvelle page /produits : recherche par libellé ou par code centrale, puis fiche complète d'un produit — identité, prix, stock par magasin, ventes sur 12 mois glissants, et l'intégralité des métriques réseau Qlik. Jusqu'ici les données produit n'étaient accessibles que par fournisseur (la Grille) ou par classement (Hit Parade) : aucune route n'acceptait un codein. Les données réseau Qlik n'étaient visibles que dans six colonnes de la grille d'arbitrage. Deux analyses ajoutées : - comparatif local vs réseau, ramené au magasin moyen (indice de performance sur les quantités, seule base commune fiable — le CA local est TTC et la base du CA Qlik n'est pas documentée, donc indicatif) ; - opportunités de la même famille : références que le réseau vend bien et que nous vendons peu, écart calculé par magasin. Détail mensuel réseau enrichi : le cube Qlik renvoyait déjà CA, nb de magasins, CA/magasin et marge % par (article, mois) mais seule la quantité était conservée. Ces mesures sont désormais stockées dans une nouvelle colonne metrics_by_month, sans requête Qlik supplémentaire. qte_by_month reste inchangée — la colonne « Tendance / Réseau » de la Grille en dépend. L'extraction Qlik peut désormais cibler un seul code centrale (/api/qlik/sync?codeCentrale=…), ouverte à tout utilisateur connecté et plafonnée à 3 extractions simultanées ; le mode fournisseur reste réservé aux admins. Refactor sans changement de comportement : les helpers de mois, le calcul de tendance et les graphes SVG quittent heatmap-grid.tsx pour des modules partagés, et le polling des jobs Qlik devient le hook useQlikSyncJob, réutilisé par la Grille et par la fiche. Les calculs de ventes reprennent le SQL canonique de la Grille (genremvt=3 avec quantités niées pour déduire les retours, stock de fin de mois pris sur le dernier mouvement, report sur les mois sans mouvement), afin que les chiffres des deux pages soient identiques par construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
This commit is contained in:
25 files changed
+2694
-465
No files matched your search
@@ -15,8 +15,7 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
const NB_MAGASINS_RESEAU = 270;
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -26,64 +26,19 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
|
||||
|
||||
function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns premium styling and color for stores based on the store name's hash.
|
||||
* This guarantees a consistent color for a given store, regardless of its position in the list.
|
||||
*/
|
||||
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
import {
|
||||
getLast12Months,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
SITE_LABELS,
|
||||
getStoreConfig,
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -238,135 +193,6 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
// ─── Tendance réseau (à partir de qteReseauByMonth) ───────────────────────────
|
||||
type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
direction: "up" | "down" | "flat";
|
||||
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
|
||||
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
|
||||
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
|
||||
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
|
||||
} else if (slope > 0) {
|
||||
direction = "up";
|
||||
}
|
||||
} else if (n === 2 && values[0] > 0) {
|
||||
pct = (values[1] - values[0]) / values[0];
|
||||
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
|
||||
}
|
||||
return { values, labels, direction, pct, hasData: true };
|
||||
}
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
const TREND_STRONG = 0.25;
|
||||
|
||||
const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||
function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "2026-01" → "01/26" pour un axe compact. */
|
||||
function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Courbe unique des ventes réseau mensuelles (SVG). */
|
||||
function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
|
||||
const W = 460, H = 200;
|
||||
const padL = 10, padR = 10, padT = 22, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = values.length;
|
||||
const maxV = Math.max(...values, 1);
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
|
||||
return (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
||||
@@ -383,8 +209,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
|
||||
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||
{pct != null && (
|
||||
<> · <span className="font-bold" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "}
|
||||
{pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"}
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||
</span></>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
|
||||
* produit (`/produits`). Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { fmtMonthShort } from "@/features/grid/lib/months";
|
||||
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Courbe unique des ventes réseau mensuelles (SVG). */
|
||||
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
|
||||
const W = 460, H = 200;
|
||||
const padL = 10, padR = 10, padT = 22, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = values.length;
|
||||
const maxV = Math.max(...values, 1);
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
|
||||
return (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
|
||||
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
|
||||
*/
|
||||
export function DualLineChart({
|
||||
labels,
|
||||
series,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[]; color: string }>;
|
||||
}) {
|
||||
const W = 620, H = 220;
|
||||
const padL = 10, padR = 10, padT = 26, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = labels.length;
|
||||
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
{series.map((s) => (
|
||||
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{series.map((s) => {
|
||||
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
return (
|
||||
<g key={s.name}>
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
{s.values.map((v, i) => (
|
||||
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{labels.map((lab, i) => (
|
||||
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
interface SyncQlikButtonProps {
|
||||
/** Code fournisseur affiché (pour sync ciblée) */
|
||||
@@ -11,24 +12,8 @@ interface SyncQlikButtonProps {
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync */
|
||||
interface SyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
/** Date + heure de dernière synchro, ou "jamais". */
|
||||
function formatLastUpdate(iso?: string | null): string {
|
||||
if (!iso) return "jamais";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "jamais";
|
||||
@@ -39,55 +24,15 @@ function formatDate(iso?: string | null): string {
|
||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||
*
|
||||
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
|
||||
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
|
||||
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
|
||||
* la page) et continuer à afficher le spinner correctement.
|
||||
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||
const trackingRef = useRef<boolean>(false);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Met à jour le label visible à droite du bouton selon l'état courant.
|
||||
*/
|
||||
const applyJobToUi = useCallback((j: SyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
// Texte informatif pendant l'extraction
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
|
||||
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||
} else if (j.status === "success") {
|
||||
setStatus("success");
|
||||
if (j.message) setMessage(j.message);
|
||||
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||
} else if (j.status === "error") {
|
||||
setStatus("error");
|
||||
setMessage(j.error || "Erreur sync Qlik");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
|
||||
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
|
||||
*/
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
@@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
/**
|
||||
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
|
||||
* passe en success/error, ou après un timeout de sécurité.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!codeFournisseur) return false;
|
||||
if (!trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
// En cas d'erreur HTTP transitoire on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: SyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") {
|
||||
// Continue à poller
|
||||
return true;
|
||||
}
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
// Terminé : on rafraîchit la grille, on coupe le polling.
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
// idle : rien à faire
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire, on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, codeFournisseur, refreshGrid]);
|
||||
|
||||
/**
|
||||
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
|
||||
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
|
||||
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, 3000);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce]);
|
||||
|
||||
/**
|
||||
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
|
||||
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
|
||||
* oui, on reprend l'état et on relance le polling.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!codeFournisseur) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return;
|
||||
const data: SyncJobState = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
} else if (data.status === "success" || data.status === "error") {
|
||||
// Job terminé en amont (avant le mount) : on l'affiche brièvement
|
||||
applyJobToUi(data);
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
}
|
||||
} catch {
|
||||
/* ignore : on n'empêche pas l'UI d'afficher le bouton */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au mount (et quand le fournisseur change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [codeFournisseur, clearPollTimer]);
|
||||
|
||||
/**
|
||||
* Cleanup : annule un timer de polling éventuel au démontage.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [clearPollTimer]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
return;
|
||||
}
|
||||
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||
if (status === "running") return;
|
||||
|
||||
setStatus("running");
|
||||
setMessage("Démarrage de l'extraction Qlik…");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const data: SyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || (data as { success?: boolean }).success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
|
||||
// on reprend juste le suivi.
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Status terminal direct (cas rare : 0 codes → success immédiat)
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
? message
|
||||
: status === "running"
|
||||
? (message || `Extraction Qlik…`)
|
||||
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
|
||||
* fiche produit (`/produits`). Aucun changement de comportement.
|
||||
*
|
||||
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
|
||||
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
|
||||
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
|
||||
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
|
||||
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
|
||||
*
|
||||
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
|
||||
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
||||
* entre le rendu serveur et le rendu client.
|
||||
*/
|
||||
export function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** `"202601"` → `"Jan 26"`. */
|
||||
export function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
|
||||
export function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
|
||||
export function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
|
||||
export function ffMonthToQlik(key: string): string {
|
||||
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
|
||||
export function qlikMonthToFf(key: string): string {
|
||||
return key.replace("-", "");
|
||||
}
|
||||
|
||||
/** Les deux magasins FF Nancy suivis par l'application. */
|
||||
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
||||
* nom, donc identique quel que soit l'ordre d'affichage.
|
||||
*/
|
||||
export function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
|
||||
*
|
||||
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
|
||||
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
|
||||
* Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
|
||||
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
|
||||
*/
|
||||
export const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
direction: "up" | "down" | "flat";
|
||||
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
|
||||
export const TREND_FLAT = 0.08;
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
|
||||
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
|
||||
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
|
||||
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
} else if (slope > 0) {
|
||||
direction = "up";
|
||||
}
|
||||
} else if (n === 2 && values[0] > 0) {
|
||||
pct = (values[1] - values[0]) / values[0];
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
}
|
||||
return { values, labels, direction, pct, hasData: true };
|
||||
}
|
||||
|
||||
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
|
||||
export function trendLabel(pct: number | null): string {
|
||||
if (pct == null) return "Stable";
|
||||
if (pct > TREND_STRONG) return "Forte hausse";
|
||||
if (pct > TREND_FLAT) return "Hausse";
|
||||
if (pct < -TREND_STRONG) return "Forte baisse";
|
||||
if (pct < -TREND_FLAT) return "Baisse";
|
||||
return "Stable";
|
||||
}
|
||||
Reference in new issue
Block a user