diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx new file mode 100644 index 0000000..e61cb4a --- /dev/null +++ b/apps/web/src/components/dashboard/ActivityFeed.tsx @@ -0,0 +1,97 @@ +import { format, parseISO } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react"; + +import { Card } from "@/components/ui/Card"; +import { Eyebrow } from "@/components/ui/Eyebrow"; +import { cn } from "@/lib/utils"; + +/** + * ActivityFeed — journal d'activité. + * Pattern wireframe 4.1 : ce qui s'est passé pendant que l'user travaillait + * sur son vrai métier (intention émotionnelle forte). + */ +export type ActivityKind = + | "relance_sent" + | "invoice_paid" + | "invoice_imported" + | "warning_drafted"; + +export type ActivityEvent = { + id: string; + kind: ActivityKind; + /** ISO 8601 */ + at: string; + /** Texte HTML simple — peut contenir pour mettre en avant un nom. */ + label: string; +}; + +const ICONS: Record = { + relance_sent: Send, + invoice_paid: CheckCircle2, + invoice_imported: Inbox, + warning_drafted: AlertTriangle, +}; + +const TONE: Record = { + relance_sent: "text-ink-2", + invoice_paid: "text-rubis-deep", + invoice_imported: "text-ink-2", + warning_drafted: "text-rubis-deep", +}; + +type ActivityFeedProps = { + events: ActivityEvent[]; + /** Si la liste est vide, on affiche un empty state au lieu d'une carte vide. */ + emptyMessage?: string; + className?: string; +}; + +export function ActivityFeed({ + events, + emptyMessage = "Tout est calme. Allez vous chercher un café.", + className, +}: ActivityFeedProps) { + return ( + + Aujourd'hui + + {events.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
    + {events.map((event) => { + const Icon = ICONS[event.kind]; + return ( +
  • +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/KpiCard.tsx b/apps/web/src/components/dashboard/KpiCard.tsx new file mode 100644 index 0000000..50ec6c1 --- /dev/null +++ b/apps/web/src/components/dashboard/KpiCard.tsx @@ -0,0 +1,49 @@ +import { Card } from "@/components/ui/Card"; +import { cn } from "@/lib/utils"; + +/** + * KpiCard — un bloc statistique scannable. + * + * Personnalité : + * - Label en eyebrow caps, value en font-display gros, delta en muted small + * - Pas de couleur "verte succès" — on garde la palette rubis/neutres + * - delta neutre : ink-3, delta positif : rubis-deep, delta négatif : ink-3 (jamais rouge alerte) + * + * "Positif" est dans le sens de la métrique : + * - "Encaissé +2 800 €" : positif (intent=positive) + * - "DSO -6j" (DSO baisse → c'est bien) : positif (intent=positive) + * - On laisse l'utilisateur déclarer l'intent. + */ +type KpiCardProps = { + label: string; + value: string; + delta?: string; + /** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */ + intent?: "positive" | "neutral" | "warning"; + className?: string; +}; + +export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) { + return ( + +

+ {label} +

+

+ {value} +

+ {delta && ( +

+ {delta} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/RubisHero.tsx b/apps/web/src/components/dashboard/RubisHero.tsx new file mode 100644 index 0000000..dc15c29 --- /dev/null +++ b/apps/web/src/components/dashboard/RubisHero.tsx @@ -0,0 +1,93 @@ +import { Gem } from "@/components/brand/Gem"; +import { Card } from "@/components/ui/Card"; +import { formatRubisToHours } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +/** + * RubisHero — la pièce centrale du dashboard. + * Cf. wireframe 4.1 : ◆ géant + "X rubis gagnés ce mois" + verbalisation + * de la conversion en heures + barre de progression vers l'objectif mensuel. + * + * Layout asymétrique : + * - Mobile : centré, gem en haut + * - Desktop : gem à gauche (XL), texte au milieu, progression à droite + * + * Pas une carte plate "X stat" : c'est l'identité émotionnelle de l'app. + */ +type RubisHeroProps = { + rubisThisMonth: number; + /** Pourcentage 0-100 vers l'objectif. */ + monthlyGoalProgress: number; + /** Top X% des utilisateurs sur le mois (si disponible). */ + percentile?: number; + className?: string; +}; + +export function RubisHero({ + rubisThisMonth, + monthlyGoalProgress, + percentile, + className, +}: RubisHeroProps) { + const progress = Math.min(100, Math.max(0, monthlyGoalProgress)); + const hoursLabel = formatRubisToHours(rubisThisMonth); + + return ( + + {/* Halo rubis-glow en arrière-plan, très discret */} +