feat: hit-parade date range + fournisseur filter; fix create user button layout

- Replace month dropdown with date picker (Du/Au) + Appliquer button
- Add fournisseur filter dropdown (client-side, all suppliers from current data)
- Totals update live when fournisseur filter changes
- SQL: use BETWEEN date range instead of TO_CHAR month match
- Create user button: proper 3-col form layout so button stays on one line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-04-01 13:42:25 +02:00
1 parent 05354a0e74
commit 868886db87
4 files changed
+160 -119

No files matched your search

+108 -76
View File
@@ -22,24 +22,25 @@ function formatPct(ca: number, marge: number) {
} }
interface Props { interface Props {
mois: string; dateDebut: string;
currentMois: string; dateFin: string;
pivotted: HitParadePivotRow[]; pivotted: HitParadePivotRow[];
} }
export function HitParadeClient({ mois, pivotted }: Props) { export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const router = useRouter(); const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT); const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR); const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR);
const [filterFournisseur, setFilterFournisseur] = useState<string>("Tous");
const months: { value: string; label: string }[] = []; // Dates locales pour les inputs (évite rechargement à chaque frappe)
const now = new Date(); const [localDebut, setLocalDebut] = useState(dateDebut);
for (let i = 0; i < 24; i++) { const [localFin, setLocalFin] = useState(dateFin);
const d = new Date(now.getFullYear(), now.getMonth() - i);
const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; const fournisseurs = useMemo(() => {
const label = new Intl.DateTimeFormat("fr-FR", { year: "numeric", month: "long" }).format(d); const set = new Set(pivotted.map(r => r.fournisseur));
months.push({ value, label: label.charAt(0).toUpperCase() + label.slice(1) }); return ["Tous", ...Array.from(set).sort((a, b) => a.localeCompare(b, "fr"))];
} }, [pivotted]);
function handleSort(key: SortKey) { function handleSort(key: SortKey) {
if (sortKey === key) { if (sortKey === key) {
@@ -55,25 +56,37 @@ export function HitParadeClient({ mois, pivotted }: Props) {
setSortDir(DEFAULT_DIR); setSortDir(DEFAULT_DIR);
} }
function applyDates() {
if (localDebut && localFin) {
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
}
}
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR; const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
const sorted = useMemo(() => { const filtered = useMemo(() =>
return [...pivotted].sort((a, b) => { filterFournisseur === "Tous" ? pivotted : pivotted.filter(r => r.fournisseur === filterFournisseur),
[pivotted, filterFournisseur]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey]; const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff; return sortDir === "desc" ? -diff : diff;
}); }),
}, [pivotted, sortKey, sortDir]); [filtered, sortKey, sortDir]
);
const totals = useMemo(() => pivotted.reduce( const totals = useMemo(() => sorted.reduce(
(acc, r) => ({ (acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292, qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579, qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal, qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
}), }),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0 } { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0 }
), [pivotted]); ), [sorted]);
function ColHeader({ label, sortable, k }: { label: string; sortable?: SortKey; k?: string }) { function ColHeader({ label, sortable, k }: { label: string; sortable?: SortKey; k: string }) {
const isActive = sortable && sortKey === sortable; const isActive = sortable && sortKey === sortable;
return ( return (
<th <th
@@ -98,45 +111,71 @@ export function HitParadeClient({ mois, pivotted }: Props) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Barre de contrôles */} {/* Barre de contrôles */}
<div className="flex flex-wrap items-center gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100"> <div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
<div className="flex items-center gap-2"> {/* Période */}
<span className="text-sm font-semibold text-gray-600">Mois</span> <div className="flex items-end gap-3">
<select <div className="flex flex-col gap-1">
value={mois} <label className="text-xs font-semibold text-gray-500">Du</label>
onChange={e => router.push(`/hit-parade?mois=${e.target.value}`)} <input
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" type="date"
value={localDebut}
onChange={e => setLocalDebut(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Au</label>
<input
type="date"
value={localFin}
onChange={e => setLocalFin(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
<button
onClick={applyDates}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
> >
{months.map(m => ( Appliquer
<option key={m.value} value={m.value}>{m.label}</option> </button>
</div>
<div className="h-8 w-px bg-gray-200 self-center" />
{/* Filtre fournisseur */}
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Fournisseur</label>
<select
value={filterFournisseur}
onChange={e => setFilterFournisseur(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400 min-w-[200px]"
>
{fournisseurs.map(f => (
<option key={f} value={f}>{f}</option>
))} ))}
</select> </select>
</div> </div>
<div className="h-6 w-px bg-gray-200" /> <div className="h-8 w-px bg-gray-200 self-center" />
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600"> <span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
{sorted.length} produits {sorted.length} produits
</span> </span>
{isSorted && ( {isSorted && (
<button <button
onClick={resetSort} onClick={resetSort}
className="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-sm font-medium text-orange-700 hover:bg-orange-100 transition-colors" className="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-sm font-medium text-orange-700 hover:bg-orange-100 transition-colors self-center"
> >
<span>✕</span> Annuler le tri <span>✕</span> Annuler le tri
</button> </button>
)} )}
{!isSorted && (
<span className="text-xs text-gray-400 italic">Trié par CA Total ↓ — cliquez sur une colonne pour trier</span>
)}
</div> </div>
{/* Tableau */} {/* Tableau */}
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100"> <div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
{/* Ligne 1 — groupes */}
<tr> <tr>
<th rowSpan={2} className="w-24 border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th> <th rowSpan={2} className="w-24 border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th> <th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
@@ -151,7 +190,6 @@ export function HitParadeClient({ mois, pivotted }: Props) {
Total Réseau Total Réseau
</th> </th>
</tr> </tr>
{/* Ligne 2 — sous-colonnes */}
<tr className="border-b-2 border-gray-200"> <tr className="border-b-2 border-gray-200">
<ColHeader label="Qté" sortable="qte292" k="qte292" /> <ColHeader label="Qté" sortable="qte292" k="qte292" />
<ColHeader label="CA TTC" sortable="ca292" k="ca292" /> <ColHeader label="CA TTC" sortable="ca292" k="ca292" />
@@ -165,46 +203,40 @@ export function HitParadeClient({ mois, pivotted }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((row, idx) => { {sorted.map((row, idx) => (
const highlight = sortKey.includes("292") ? "bg-blue-50/30" : sortKey.includes("579") ? "bg-violet-50/30" : ""; <tr key={row.codein} className={`border-b border-gray-100 ${idx % 2 === 0 ? "bg-white" : "bg-gray-50/60"} hover:bg-yellow-50/40 transition-colors`}>
return ( <td className="px-4 py-2.5 font-mono text-xs text-gray-400">{row.codein}</td>
<tr key={row.codein} className={`border-b border-gray-100 ${idx % 2 === 0 ? "bg-white" : "bg-gray-50/60"} hover:bg-yellow-50/40 transition-colors`}> <td className="px-4 py-2.5 text-gray-900 font-medium max-w-[260px] truncate" title={row.libelle.trim()}>{row.libelle.trim()}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-400">{row.codein}</td> <td className="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
<td className="px-4 py-2.5 text-gray-900 font-medium max-w-[260px] truncate" title={row.libelle.trim()}>{row.libelle.trim()}</td> <td className={`border-l-2 border-blue-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-50/50 font-semibold" : ""}`}>
<td className="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td> {row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>}
{/* 292 */} </td>
<td className={`border-l-2 border-blue-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-50/50 font-semibold" : ""}`}> <td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-50/50 font-semibold" : ""}`}>
{row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>} {row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>}
</td> </td>
<td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-50/50 font-semibold" : ""}`}> <td className="px-4 py-2.5 text-right text-xs tabular-nums text-blue-500">
{row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>} {row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}
</td> </td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-blue-500"> <td className={`border-l-2 border-violet-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-50/50 font-semibold" : ""}`}>
{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""} {row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>}
</td> </td>
{/* 579 */} <td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-50/50 font-semibold" : ""}`}>
<td className={`border-l-2 border-violet-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-50/50 font-semibold" : ""}`}> {row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>}
{row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>} </td>
</td> <td className="px-4 py-2.5 text-right text-xs tabular-nums text-violet-500">
<td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-50/50 font-semibold" : ""}`}> {row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}
{row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>} </td>
</td> <td className={`border-l-2 border-emerald-100 px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-50/50" : ""}`}>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-violet-500"> {row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>}
{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""} </td>
</td> <td className={`px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-50/50" : ""}`}>
{/* Total */} {row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>}
<td className={`border-l-2 border-emerald-100 px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-50/50" : ""}`}> </td>
{row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>} <td className="px-4 py-2.5 text-right text-xs tabular-nums text-emerald-600">
</td> {row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
<td className={`px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-50/50" : ""}`}> </td>
{row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>} </tr>
</td> ))}
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-emerald-600">
{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
</td>
</tr>
);
})}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-100"> <tr className="border-t-2 border-gray-300 bg-gray-100">
+13 -6
View File
@@ -53,15 +53,22 @@ function pivotHitParade(
return Array.from(map.values()); return Array.from(map.values());
} }
function defaultDates() {
const today = new Date();
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
return { debut: fmt(firstOfMonth), fin: fmt(today) };
}
export default async function HitParadePage(props: { export default async function HitParadePage(props: {
searchParams: Promise<Record<string, string | string[]>>; searchParams: Promise<Record<string, string | string[]>>;
}) { }) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const today = new Date(); const defaults = defaultDates();
const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; const dateDebut = (searchParams.debut as string) || defaults.debut;
const mois = (searchParams.mois as string) || currentMois; const dateFin = (searchParams.fin as string) || defaults.fin;
const rows = await pgGetHitParade(mois); const rows = await pgGetHitParade(dateDebut, dateFin);
const pivotted = pivotHitParade(rows); const pivotted = pivotHitParade(rows);
return ( return (
@@ -69,8 +76,8 @@ export default async function HitParadePage(props: {
<div className="mx-auto max-w-screen-2xl"> <div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1> <h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1>
<HitParadeClient <HitParadeClient
mois={mois} dateDebut={dateDebut}
currentMois={currentMois} dateFin={dateFin}
pivotted={pivotted} pivotted={pivotted}
/> />
</div> </div>
@@ -111,43 +111,45 @@ export function UserManagement() {
</div> </div>
</div> </div>
<form onSubmit={handleCreate} className="grid grid-cols-1 md:grid-cols-4 gap-6 items-end"> <form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label> <div className="space-y-2">
<input <label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label>
required <input
value={newUsername} required
onChange={(e) => setNewUsername(e.target.value)} value={newUsername}
className="apple-input" onChange={(e) => setNewUsername(e.target.value)}
placeholder="ex: jean.dupont" className="apple-input"
/> placeholder="ex: jean.dupont"
</div> />
<div className="space-y-2"> </div>
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Mot de passe</label> <div className="space-y-2">
<input <label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Mot de passe</label>
required <input
type="password" required
value={newPassword} type="password"
onChange={(e) => setNewPassword(e.target.value)} value={newPassword}
className="apple-input" onChange={(e) => setNewPassword(e.target.value)}
placeholder="••••••••" className="apple-input"
/> placeholder="••••••••"
</div> />
<div className="space-y-2"> </div>
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Rôle</label> <div className="space-y-2">
<select <label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Rôle</label>
value={newRole} <select
onChange={(e) => setNewRole(e.target.value as any)} value={newRole}
className="apple-input" onChange={(e) => setNewRole(e.target.value as any)}
> className="apple-input"
<option value="user">Utilisateur</option> >
<option value="admin">Administrateur</option> <option value="user">Utilisateur</option>
</select> <option value="admin">Administrateur</option>
</select>
</div>
</div> </div>
<button <button
type="submit" type="submit"
disabled={isCreating} disabled={isCreating}
className="apple-btn-primary w-full justify-center !h-auto py-2.5" className="apple-btn-primary"
> >
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Créer le compte Créer le compte
+4 -4
View File
@@ -709,9 +709,9 @@ export interface HitParadeRow {
} }
/** /**
* Retourne les ventes produit pour un mois donné, avec fournisseur, qté, CA TTC et marge par site. * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC et marge par site.
*/ */
export async function pgGetHitParade(mois: string): Promise<HitParadeRow[]> { export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise<HitParadeRow[]> {
const result = await pgNoParallel(sql` const result = await pgNoParallel(sql`
SELECT SELECT
a.codein::text AS codein, a.codein::text AS codein,
@@ -725,13 +725,13 @@ export async function pgGetHitParade(mois: string): Promise<HitParadeRow[]> {
JOIN articles a ON a.no_id = m.artnoid JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
LEFT JOIN fouident fi ON fi.code = af.code LEFT JOIN fouident fi ON fi.code = af.code
WHERE TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579') AND m.site IN ('292', '579')
AND m.genremvt = 3 AND m.genremvt = 3
GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site
ORDER BY ca_ttc DESC ORDER BY ca_ttc DESC
`); `);
console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${mois}`); console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`);
return result.rows as unknown as HitParadeRow[]; return result.rows as unknown as HitParadeRow[];
} }