TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Primitives partagées du tableau de bord d'administration : aides de format,3// récupération JSON, tuiles de stats, interrupteur accessible, graphiques en barres maison.4import { useCallback, useEffect, useState, type ReactNode } from "react";5import { AlertTriangle } from "lucide-react";6import { Button, Card, cn } from "@/components/ui";78// ---------- Formats (fr-CA) ----------9export function fmtInt(n: number | null | undefined): string {10 return (n ?? 0).toLocaleString("fr-CA");11}1213export function fmtUSD(n: number | null | undefined, digits = 2): string {14 const v = n ?? 0;15 return `${v.toLocaleString("fr-CA", { minimumFractionDigits: digits, maximumFractionDigits: digits })} $`;16}1718/** SQLite stocke datetime('now') en UTC sans fuseau — on normalise avant d'afficher. */19export function fmtDate(s: string | null | undefined, withTime = true): string {20 if (!s) return "—";21 const iso = s.includes("T") ? s : s.replace(" ", "T") + "Z";22 const d = new Date(iso);23 if (Number.isNaN(d.getTime())) return s;24 return d.toLocaleString("fr-CA", withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });25}2627// ---------- Récupération JSON ----------28export async function postJson<T = { ok: boolean }>(url: string, body?: unknown, method = "POST"): Promise<T> {29 const res = await fetch(url, {30 method,31 headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,32 body: body !== undefined ? JSON.stringify(body) : undefined,33 });34 const j = (await res.json().catch(() => null)) as { error?: string } | null;35 if (!res.ok) throw new Error(j?.error || `Erreur ${res.status}`);36 return j as T;37}3839export function useFetchJson<T>(url: string) {40 const [data, setData] = useState<T | null>(null);41 const [error, setError] = useState<string | null>(null);42 const [loading, setLoading] = useState(true);43 const reload = useCallback(async () => {44 setLoading(true);45 setError(null);46 try {47 const res = await fetch(url);48 const j = (await res.json().catch(() => null)) as (T & { error?: string }) | null;49 if (!res.ok || !j) throw new Error(j?.error || `Erreur ${res.status}`);50 setData(j);51 } catch (e) {52 setError(e instanceof Error ? e.message : "Erreur inattendue.");53 } finally {54 setLoading(false);55 }56 }, [url]);57 useEffect(() => {58 reload();59 }, [reload]);60 return { data, error, loading, reload, setData };61}6263// ---------- Bannière d'erreur ----------64export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {65 return (66 <div className="flex items-center gap-3 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-400" role="alert">67 <AlertTriangle size={16} className="shrink-0" />68 <span className="flex-1">{message}</span>69 {onRetry && (70 <Button size="sm" variant="secondary" onClick={onRetry}>71 Réessayer72 </Button>73 )}74 </div>75 );76}7778// ---------- Message de succès transitoire ----------79export function SuccessFlash({ message }: { message: string | null }) {80 if (!message) return null;81 return (82 <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-700 dark:text-emerald-400 animate-fade-in" role="status">83 {message}84 </div>85 );86}8788// ---------- Tuile de statistique ----------89export function StatTile({90 label,91 value,92 sub,93 tone = "default",94}: {95 label: string;96 value: ReactNode;97 sub?: ReactNode;98 tone?: "default" | "red" | "amber" | "green";99}) {100 const tones = {101 default: "text-fg",102 red: "text-red-600 dark:text-red-400",103 amber: "text-amber-600 dark:text-amber-400",104 green: "text-emerald-600 dark:text-emerald-400",105 };106 return (107 <Card className="p-4">108 <p className="text-[12px] font-medium text-muted">{label}</p>109 <p className={cn("mt-1 text-xl font-bold tabular-nums leading-tight", tones[tone])}>{value}</p>110 {sub && <p className="mt-0.5 text-[12px] text-muted">{sub}</p>}111 </Card>112 );113}114115// ---------- Interrupteur accessible ----------116export function Toggle({117 checked,118 onChange,119 disabled,120 label,121}: {122 checked: boolean;123 onChange: (v: boolean) => void;124 disabled?: boolean;125 label?: string;126}) {127 return (128 <button129 type="button"130 role="switch"131 aria-checked={checked}132 aria-label={label}133 disabled={disabled}134 onClick={() => onChange(!checked)}135 className={cn(136 "relative inline-flex h-5.5 w-10 shrink-0 items-center rounded-full transition-colors duration-150",137 checked ? "bg-brand-600" : "bg-surface-3 dark:bg-brand-900",138 disabled && "opacity-50 cursor-not-allowed"139 )}140 >141 <span142 className={cn(143 "inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-150",144 checked ? "translate-x-[1.25rem]" : "translate-x-[0.2rem]"145 )}146 />147 </button>148 );149}150151// ---------- Graphique en barres vertical (CSS, sans dépendance) ----------152export type BarDatum = { label: string; value: number; hint?: string };153154export function BarChart({155 data,156 height = 150,157 barClass = "bg-brand-500",158 formatValue = (v: number) => fmtInt(v),159 emptyLabel = "Aucune donnée sur la période.",160}: {161 data: BarDatum[];162 height?: number;163 barClass?: string;164 formatValue?: (v: number) => string;165 emptyLabel?: string;166}) {167 const max = Math.max(...data.map((d) => d.value), 0);168 if (data.length === 0 || max <= 0) {169 return <p className="py-8 text-center text-sm text-muted">{emptyLabel}</p>;170 }171 return (172 <div>173 <div className="flex items-end gap-[3px]" style={{ height }} role="img" aria-label="Graphique en barres">174 {data.map((d, i) => (175 <div key={i} className="group relative flex h-full min-w-0 flex-1 flex-col justify-end">176 <div177 className={cn("w-full rounded-t-[4px] transition-opacity group-hover:opacity-75", d.value > 0 ? barClass : "bg-surface-3 dark:bg-brand-900/60")}178 style={{ height: d.value > 0 ? `${Math.max(3, (d.value / max) * 100)}%` : "2px" }}179 />180 <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-lg border border-app bg-card px-2.5 py-1.5 text-[12px] shadow-lg group-hover:block">181 <span className="text-muted">{d.label}</span>{" "}182 <span className="font-semibold tabular-nums text-fg">{formatValue(d.value)}</span>183 {d.hint && <span className="text-muted"> · {d.hint}</span>}184 </div>185 </div>186 ))}187 </div>188 <div className="mt-1.5 flex justify-between text-[11px] text-muted">189 <span>{data[0]?.label}</span>190 <span>{data[data.length - 1]?.label}</span>191 </div>192 </div>193 );194}195196// ---------- Barre horizontale (maîtrise, erreurs récurrentes) ----------197export function HBarRow({198 label,199 meta,200 value,201 display,202 barClass = "bg-brand-500",203 muted = false,204}: {205 label: ReactNode;206 meta?: ReactNode;207 value: number; // 0..1208 display: ReactNode;209 barClass?: string;210 muted?: boolean;211}) {212 return (213 <div className="py-1.5">214 <div className="mb-1 flex items-baseline justify-between gap-3 text-[13px]">215 <span className={cn("min-w-0 truncate font-medium", muted ? "text-muted" : "text-fg")}>{label}</span>216 <span className="shrink-0 tabular-nums text-muted">{display}</span>217 </div>218 <div className="flex items-center gap-2">219 <div className="h-2 flex-1 overflow-hidden rounded-full bg-surface-2 dark:bg-brand-900/60">220 <div221 className={cn("h-full rounded-full transition-all duration-500", muted ? "bg-surface-3 dark:bg-brand-900" : barClass)}222 style={{ width: `${Math.min(100, Math.max(0, value * 100))}%` }}223 />224 </div>225 {meta}226 </div>227 </div>228 );229}230231// ---------- En-tête de section ----------232export function SectionTitle({ children, sub }: { children: ReactNode; sub?: ReactNode }) {233 return (234 <div className="mb-3">235 <h2 className="text-[15px] font-semibold text-fg">{children}</h2>236 {sub && <p className="mt-0.5 text-[12.5px] text-muted">{sub}</p>}237 </div>238 );239}240241// ---------- Squelette de tableau ----------242export function TableSkeleton({ rows = 6 }: { rows?: number }) {243 return (244 <div className="space-y-2.5" aria-hidden>245 {Array.from({ length: rows }).map((_, i) => (246 <div key={i} className="skeleton h-9 w-full" />247 ))}248 </div>249 );250}251