mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide
This commit is contained in:
1 parent
f540e4f2b3
commit
3544c0c898
67 files changed
+19476
-15
No files matched your search
@@ -0,0 +1,14 @@
|
||||
export default function ExportsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Exports</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Génération des fichiers Excel différentiels pour l'ERP.</p>
|
||||
</div>
|
||||
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-500 text-sm">Sélectionnez un fournisseur dans la grille, puis cliquez sur <strong className="text-slate-400">« Exporter »</strong>.</p>
|
||||
<p className="text-slate-600 text-xs mt-1">L'export génère un fichier <code className="text-slate-500">.xlsx</code> avec le delta avant/après des gammes révisées.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
|
||||
import { GridClient } from "@/features/grid/components/grid-client";
|
||||
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
|
||||
import { Suspense } from "react";
|
||||
|
||||
interface GridPageProps {
|
||||
searchParams: Promise<{ fournisseur?: string; magasin?: string }>;
|
||||
}
|
||||
|
||||
export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
const params = await searchParams;
|
||||
const codeFournisseur = params.fournisseur;
|
||||
const magasin = params.magasin ?? "TOTAL";
|
||||
|
||||
// 1. Fetch available suppliers & stores
|
||||
const [fournisseurs, magasins] = await Promise.all([
|
||||
getFournisseurs(),
|
||||
getMagasins()
|
||||
]);
|
||||
|
||||
// 2. If no supplier selected, show compact selection UI
|
||||
if (!codeFournisseur) {
|
||||
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
|
||||
}
|
||||
|
||||
// 3. Load real product data for the selected supplier
|
||||
const rows = await getGridData(codeFournisseur, magasin);
|
||||
const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur);
|
||||
|
||||
return (
|
||||
<GridClient
|
||||
initialRows={rows}
|
||||
codeFournisseur={codeFournisseur}
|
||||
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
|
||||
fournisseurs={fournisseurs}
|
||||
magasins={magasins}
|
||||
magasin={magasin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Sidebar } from "@/components/shared/sidebar";
|
||||
import { Header } from "@/components/shared/header";
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden" style={{ background: "var(--bg-base)" }}>
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-60 flex flex-col h-full overflow-hidden">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] overflow-hidden"
|
||||
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
|
||||
>
|
||||
<div className="px-5 py-4 flex items-center gap-2.5" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
|
||||
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={2} />
|
||||
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
|
||||
</div>
|
||||
<div className="px-5 py-5" style={{ background: "var(--bg-surface)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0 mt-0.5"
|
||||
style={{ background: "var(--accent-bg)", color: "var(--accent)", border: "1px solid var(--accent-border)" }}
|
||||
>
|
||||
{step}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>{title}</p>
|
||||
<code className="text-[12px] mt-1 block px-3 py-2 rounded-[8px] font-mono" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>
|
||||
{formula}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge({ value, label }: { value: string; label: string }) {
|
||||
const num = parseFloat(value);
|
||||
const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500";
|
||||
const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)";
|
||||
const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)";
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-[18px] font-black ${color} inline-flex items-center justify-center w-12 h-12 rounded-[10px]`}
|
||||
style={{ background: bg, border: `1px solid ${borderColor}` }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<p className="text-[11px] mt-1.5 font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScorePage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-2xl overflow-y-auto h-full">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
||||
Score Produit
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>
|
||||
Comprenez comment chaque produit est évalué pour le référencement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Principe fondamental */}
|
||||
<InfoCard icon={Award} title="Principe fondamental">
|
||||
<div
|
||||
className="rounded-[10px] px-4 py-3"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}
|
||||
>
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--accent)" }}>
|
||||
Un produit excellent sur <strong>une seule dimension</strong> suffit à en faire un bon produit.
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-secondary)" }}>
|
||||
Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible.
|
||||
</p>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Les 3 axes */}
|
||||
<InfoCard icon={Layers} title="Les 3 axes d'évaluation">
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Chaque produit est évalué sur son <strong>poids relatif par rapport au meilleur produit</strong> :
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Quantité", desc: "Volume vendu", icon: "📦" },
|
||||
{ label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" },
|
||||
{ label: "Marge", desc: "Profit généré", icon: "📈" },
|
||||
].map(({ label, desc, icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="px-3 py-3 rounded-[10px] text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="text-lg">{icon}</div>
|
||||
<p className="text-[12px] font-semibold mt-1" style={{ color: "var(--text-primary)" }}>{label}</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
Poids = (métrique du produit ÷ métrique du Produit N°1) × 100
|
||||
</p>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Formule */}
|
||||
<InfoCard icon={TrendingUp} title="Formule de calcul">
|
||||
<div className="space-y-4">
|
||||
<FormulaStep
|
||||
step={1}
|
||||
title="Calculer les 3 scores de base (indexés sur le TOP 100)"
|
||||
formula="score_axe = produit.métrique / top1_fournisseur × 100"
|
||||
/>
|
||||
<FormulaStep
|
||||
step={2}
|
||||
title="Prendre le meilleur axe (MAX)"
|
||||
formula="base = MAX(poids_qty, poids_ca, poids_marge)"
|
||||
/>
|
||||
<FormulaStep
|
||||
step={3}
|
||||
title="Ajouter le bonus polyvalence"
|
||||
formula="bonus = nb_axes_forts × bonus_par_axe"
|
||||
/>
|
||||
<FormulaStep
|
||||
step={4}
|
||||
title="Score final"
|
||||
formula="score = MIN(base + bonus, 100)"
|
||||
/>
|
||||
<div
|
||||
className="rounded-[10px] px-4 py-3 mt-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
<strong>Axe fort</strong> = un axe dont le poids dépasse le seuil (configurable dans Paramètres).
|
||||
Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Signification des couleurs */}
|
||||
<InfoCard icon={BarChart3} title="Lecture du score">
|
||||
<div className="flex items-center justify-center gap-6 py-2">
|
||||
<ScoreBadge value="85" label="Excellent" />
|
||||
<ScoreBadge value="55" label="Correct" />
|
||||
<ScoreBadge value="12" label="Faible" />
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>≥ 80</strong> — Produit stratégique, très forte contribution</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>50 – 79</strong> — Contribution significative, à conserver</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>< 50</strong> — Contribution faible, à réévaluer</p>
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Lien vers les paramètres */}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-[10px] transition-all hover:gap-3"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--accent)" }}
|
||||
>
|
||||
<span className="text-[13px] font-medium">Ajuster les paramètres du score</span>
|
||||
<ArrowRight className="w-4 h-4 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, BarChart3, RotateCcw } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[14px] overflow-hidden"
|
||||
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
|
||||
>
|
||||
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
|
||||
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
|
||||
{subtitle && <p className="text-[12px] mt-0.5" style={{ color: "var(--text-secondary)" }}>{subtitle}</p>}
|
||||
</div>
|
||||
<div className="px-5 py-5 space-y-4" style={{ background: "var(--bg-surface)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium" style={{ color: "var(--text-secondary)" }}>{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
||||
|
||||
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
|
||||
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
|
||||
const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort);
|
||||
const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe);
|
||||
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
|
||||
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle");
|
||||
const [models, setModels] = useState<OpenRouterModel[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5");
|
||||
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
const fetchModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setModels(data.models ?? []);
|
||||
setModelsStatus("ok");
|
||||
} catch {
|
||||
setModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const testConnection = async () => {
|
||||
setDbStatus("testing");
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setDbStatus("error");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>Paramètres</h1>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>Configuration de la connexion et des services.</p>
|
||||
</div>
|
||||
|
||||
{/* PostgreSQL */}
|
||||
<Section title="PostgreSQL" subtitle="Connexion à votre instance Docker ou distante">
|
||||
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
|
||||
<input type="text" placeholder="localhost" className="apple-input font-mono" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Port">
|
||||
<input type="text" placeholder="5432" defaultValue="5432" className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Base de données">
|
||||
<input type="text" placeholder="collectflow" className="apple-input font-mono" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Utilisateur">
|
||||
<input type="text" placeholder="postgres" className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Mot de passe">
|
||||
<input type="password" placeholder="••••••••" className="apple-input font-mono" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<input type="checkbox" id="ssl" className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" />
|
||||
<label htmlFor="ssl" className="text-[12px] cursor-pointer" style={{ color: "var(--text-secondary)" }}>Connexion SSL</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={testConnection} disabled={dbStatus === "testing"} className="apple-btn-secondary">
|
||||
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
|
||||
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||
Tester la connexion
|
||||
</button>
|
||||
{dbStatus === "error" && (
|
||||
<span className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Connexion refusée — vérifiez les paramètres</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
placeholder="sk-or-v1-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchModels(apiKey)}
|
||||
disabled={!apiKey || modelsStatus === "loading"}
|
||||
className="apple-btn-secondary whitespace-nowrap"
|
||||
>
|
||||
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
|
||||
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={models.length === 0}
|
||||
className="apple-input"
|
||||
>
|
||||
{models.length === 0
|
||||
? <option>— Modèles non chargés —</option>
|
||||
: models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.free ? "🆓 " : ""}{m.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Apparence */}
|
||||
<Section title="Apparence">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Thème</p>
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Interface claire ou sombre</p>
|
||||
</div>
|
||||
{/* Segment control */}
|
||||
<div className="segment-control">
|
||||
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
|
||||
<Sun className="w-3.5 h-3.5" /> Clair
|
||||
</button>
|
||||
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
|
||||
<Moon className="w-3.5 h-3.5" /> Sombre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Densité de la grille</p>
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Hauteur de ligne par défaut</p>
|
||||
</div>
|
||||
<select
|
||||
className="apple-input w-auto"
|
||||
value={displayDensity}
|
||||
onChange={(e) => setDisplayDensity(e.target.value as any)}
|
||||
>
|
||||
<option value="compact">Compact (32px)</option>
|
||||
<option value="normal">Normal (40px)</option>
|
||||
<option value="comfortable">Spacieux (48px)</option>
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Score Produit */}
|
||||
<Section title="Score Produit" subtitle="Paramètres de l'algorithme d'évaluation des performances">
|
||||
<Field
|
||||
label={`Seuil d'axe fort : ${seuilAxeFort.toFixed(1)}%`}
|
||||
hint="Pourcentage minimum du poids fournisseur pour qu'une dimension (Quantité, CA, Marge) soit considérée comme un point fort du produit."
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
step="5"
|
||||
value={seuilAxeFort}
|
||||
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value))}
|
||||
className="flex-1 accent-emerald-500 cursor-pointer"
|
||||
/>
|
||||
<div className="relative w-20">
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
step="5"
|
||||
value={seuilAxeFort}
|
||||
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value) || 30.0)}
|
||||
className="apple-input font-mono-nums text-right pr-6"
|
||||
/>
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={`Bonus par axe : +${bonusParAxe} pts`}
|
||||
hint="Points supplémentaires accordés au score final pour chaque axe dépassant le seuil."
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
value={bonusParAxe}
|
||||
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10))}
|
||||
className="flex-1 accent-emerald-500 cursor-pointer"
|
||||
/>
|
||||
<div className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
value={bonusParAxe}
|
||||
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10) || 0)}
|
||||
className="apple-input font-mono-nums text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={resetScoreDefaults}
|
||||
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-emerald-500"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Rétablir les valeurs par défaut
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); }}
|
||||
className="apple-btn-primary"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{saveStatus === "saved" ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{saveStatus === "saved" ? "Sauvegardé !" : "Sauvegarder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export default function SnapshotsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Snapshots</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Historique de vos sessions d'arbitrage sauvegardées.</p>
|
||||
</div>
|
||||
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-500 text-sm">Aucun snapshot enregistré.</p>
|
||||
<p className="text-slate-600 text-xs mt-1">Utilisez le bouton <strong className="text-slate-500">« Valider »</strong> dans la grille pour créer votre premier snapshot.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const AnalyzeSchema = z.object({
|
||||
codein: z.string(),
|
||||
libelle1: z.string(),
|
||||
totalCa: z.number(),
|
||||
tauxMarge: z.number(),
|
||||
totalQuantite: z.number(),
|
||||
sales12m: z.record(z.string(), z.number()),
|
||||
codeGamme: z.string().nullable(),
|
||||
});
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
|
||||
En analysant les données de ventes fournies, génère une recommandation de gamme concise (A=Cœur, B=Complémentaire, C=Saisonnier, Z=Sortie).
|
||||
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
|
||||
Format: "[Recommandation]: [Justification courte basée sur les données]"`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = AnalyzeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme } = parsed.data;
|
||||
|
||||
// Format monthly sales for the prompt
|
||||
const monthlySummary = Object.entries(sales12m)
|
||||
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
|
||||
.join(", ");
|
||||
|
||||
const userMessage = `Produit: "${libelle1}" (Code: ${codein})
|
||||
Gamme actuelle: ${codeGamme ?? "Non définie"}
|
||||
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
|
||||
Historique mensuel: ${monthlySummary}
|
||||
|
||||
Quelle gamme recommandes-tu et pourquoi ?`;
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow AI Copilot",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-flash-1.5",
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
max_tokens: 150,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible.";
|
||||
|
||||
return NextResponse.json({ insight, codein });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
|
||||
const ExportSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
rows: z.array(z.object({
|
||||
codein: z.string(),
|
||||
libelle1: z.string(),
|
||||
code3: z.string(),
|
||||
libelle3: z.string(),
|
||||
codeGamme: z.string().nullable(),
|
||||
totalQuantite: z.number(),
|
||||
totalCa: z.number(),
|
||||
tauxMarge: z.number(),
|
||||
// Before gamme for delta
|
||||
gammeAvant: z.string().nullable().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const parsed = ExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { nomFournisseur, rows } = parsed.data;
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "CollectFlow";
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet("Assortiment", {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
});
|
||||
|
||||
// Header style
|
||||
const headerStyle: Partial<ExcelJS.Style> = {
|
||||
font: { bold: true, color: { argb: "FFFFFFFF" } },
|
||||
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
|
||||
alignment: { horizontal: "center" },
|
||||
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
|
||||
};
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "Code", key: "codein", width: 14 },
|
||||
{ header: "Désignation", key: "libelle1", width: 40 },
|
||||
{ header: "Nomenclature", key: "libelle3", width: 25 },
|
||||
{ header: "Gamme Avant", key: "gammeAvant", width: 14 },
|
||||
{ header: "Gamme Après", key: "codeGamme", width: 14 },
|
||||
{ header: "Delta", key: "delta", width: 10 },
|
||||
{ header: "Vol. 12m", key: "totalQuantite", width: 12 },
|
||||
{ header: "CA 12m (€)", key: "totalCa", width: 14 },
|
||||
{ header: "Marge (%)", key: "tauxMarge", width: 12 },
|
||||
];
|
||||
|
||||
// Apply header style
|
||||
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
|
||||
sheet.getRow(1).height = 22;
|
||||
|
||||
// Gamme colours
|
||||
const GAMME_COLORS: Record<string, string> = {
|
||||
A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519",
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : "";
|
||||
const excelRow = sheet.addRow({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
libelle3: row.libelle3,
|
||||
gammeAvant: row.gammeAvant ?? "—",
|
||||
codeGamme: row.codeGamme ?? "—",
|
||||
delta,
|
||||
totalQuantite: Math.round(row.totalQuantite),
|
||||
totalCa: parseFloat(row.totalCa.toFixed(2)),
|
||||
tauxMarge: parseFloat(row.tauxMarge.toFixed(2)),
|
||||
});
|
||||
|
||||
// Colour the "Gamme Après" cell
|
||||
const gammeCell = excelRow.getCell("codeGamme");
|
||||
if (row.codeGamme && GAMME_COLORS[row.codeGamme]) {
|
||||
gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } };
|
||||
gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
|
||||
}
|
||||
|
||||
// Highlight changed rows
|
||||
if (delta) {
|
||||
excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } };
|
||||
}
|
||||
|
||||
// Format numbers
|
||||
excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"';
|
||||
excelRow.getCell("tauxMarge").numFmt = '0.00"%"';
|
||||
}
|
||||
|
||||
// Generate buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
|
||||
const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-openrouter-key") || process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 3600 }, // Cache 1h
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Return simplified list sorted by name
|
||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Free models first, then alphabetical
|
||||
if (a.free && !b.free) return -1;
|
||||
if (!a.free && b.free) return 1;
|
||||
return a.name.localeCompare(b.name, "fr");
|
||||
});
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,319 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
CollectFlow — Apple-inspired Design System
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ── Dark Mode (default) ── */
|
||||
:root {
|
||||
/* Apple macOS dark exact values */
|
||||
--bg-base: #000000;
|
||||
--bg-surface: #111111;
|
||||
--bg-elevated: #1c1c1e;
|
||||
--bg-overlay: rgba(28, 28, 30, 0.85);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--text-primary: #f5f5f7;
|
||||
--text-secondary: #a1a1a6;
|
||||
--text-muted: #86868b;
|
||||
|
||||
--accent: #2dd4bf;
|
||||
/* emerald-ish, Apple-tuned */
|
||||
--accent-bg: rgba(45, 212, 191, 0.10);
|
||||
--accent-border: rgba(45, 212, 191, 0.20);
|
||||
|
||||
/* Semantic UI accents */
|
||||
--accent-success: #10b981;
|
||||
--accent-success-bg: rgba(16, 185, 129, 0.10);
|
||||
--accent-warning: #f59e0b;
|
||||
--accent-warning-bg: rgba(245, 158, 11, 0.10);
|
||||
--accent-error: #f43f5e;
|
||||
--accent-error-bg: rgba(244, 63, 94, 0.10);
|
||||
|
||||
/* Primary Action / Brand */
|
||||
--brand-solid: #10b981;
|
||||
--brand-hover: #059669;
|
||||
--action-secondary-bg: #2c2c2e;
|
||||
--action-secondary-text: #f5f5f7;
|
||||
--action-secondary-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6), 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.7), 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ── Light Mode ── */
|
||||
.light {
|
||||
--bg-base: #f5f5f7;
|
||||
/* Apple light gray */
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f0f0f5;
|
||||
--bg-overlay: rgba(255, 255, 255, 0.82);
|
||||
--border: rgba(0, 0, 0, 0.07);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
|
||||
--text-primary: #1d1d1f;
|
||||
/* Apple dark */
|
||||
--text-secondary: #515154;
|
||||
--text-muted: #86868b;
|
||||
|
||||
--accent: #0d9488;
|
||||
/* deeper teal in light */
|
||||
--accent-bg: rgba(13, 148, 136, 0.08);
|
||||
--accent-border: rgba(13, 148, 136, 0.18);
|
||||
|
||||
/* Semantic UI accents (Light Mode Adjustments) */
|
||||
--accent-success: #059669;
|
||||
--accent-success-bg: rgba(5, 150, 105, 0.08);
|
||||
--accent-warning: #d97706;
|
||||
--accent-warning-bg: rgba(217, 119, 6, 0.08);
|
||||
--accent-error: #e11d48;
|
||||
--accent-error-bg: rgba(225, 29, 72, 0.08);
|
||||
|
||||
/* Primary Action / Brand (Light) */
|
||||
--brand-solid: #0d9488;
|
||||
--brand-hover: #0f766e;
|
||||
--action-secondary-bg: #ffffff;
|
||||
--action-secondary-text: #1d1d1f;
|
||||
--action-secondary-border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
/* Force no system scroll jump */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans, -apple-system, "Inter", system-ui, sans-serif);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Apple-style focus ring */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Surfaces ── */
|
||||
.surface {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.elevated {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ── Glass panels (sidebar, header) ── */
|
||||
.glass {
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
}
|
||||
|
||||
/* ── Apple border ── */
|
||||
.border-apple {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.border-apple-strong {
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
/* ── Tabular numbers & Mono ── */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.font-mono-nums {
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
/* ── Refined scrollbar ── */
|
||||
.overflow-auto::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.overflow-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.light .overflow-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Heatmap cells ── */
|
||||
.heatmap-zero {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.heatmap-low {
|
||||
background: rgba(45, 212, 191, 0.08);
|
||||
color: rgba(45, 212, 191, 0.70);
|
||||
}
|
||||
|
||||
.heatmap-mid {
|
||||
background: rgba(45, 212, 191, 0.15);
|
||||
color: rgba(45, 212, 191, 0.90);
|
||||
}
|
||||
|
||||
.heatmap-high {
|
||||
background: rgba(45, 212, 191, 0.25);
|
||||
color: #2dd4bf;
|
||||
}
|
||||
|
||||
.light .heatmap-low {
|
||||
background: rgba(13, 148, 136, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.light .heatmap-mid {
|
||||
background: rgba(13, 148, 136, 0.14);
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
.light .heatmap-high {
|
||||
background: rgba(13, 148, 136, 0.22);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
/* ── Apple-style input ── */
|
||||
.apple-input {
|
||||
width: 100%;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.apple-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.apple-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
|
||||
/* ── Apple-style button ── */
|
||||
.apple-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.apple-btn-primary:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.apple-btn-primary:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.apple-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.apple-btn-secondary:hover {
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
/* ── Segment control (theme toggle) ── */
|
||||
.segment-control {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.segment-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.segment-btn.active {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/shared/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CollectFlow — Dashboard Décisionnel",
|
||||
description: "Révision d'Assortiment Haute Performance",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/grid");
|
||||
}
|
||||
Reference in new issue
Block a user