SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
8.5 KB · 251 lines tsx
Raw Blame History
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