feat(mobile): UX cohérente sur toute l'app + check-in non-persistant

Mobile UX :
  - Inputs/Textarea : font-size base (16px) sur mobile pour bloquer le zoom
    iOS Safari au focus. Densité 15px préservée sur lg.
  - KpiCard : padding p-4 + value text-22px sur mobile, p-7 + 28px sur lg.
    Truncate pour éviter le débordement des €tots longs.
  - Mobile tab bar : "Réglages" remplacé par "+ Nouvelle" → /factures/import
    (Réglages reste accessible via l'avatar UserMenu).
  - Brand topbar mobile : cliquable → retour dashboard.
  - DemoClock : full-width mobile (inset-x-4), 300px droite sur lg.
  - DemoEmailSlide : bottom-sheet sur mobile (slide-from-bottom + drag handle
    + safe-area-inset-bottom), slide-over droit sur lg.
  - InAppCheckinModal : bottom-sheet mobile aussi, layout InvoiceCard
    stacké pour éviter le squash sur 320px.
  - Nouveau bouton "+ Nouvelle facture" dans le header /factures (visible
    desktop + mobile, link → /factures/import).
  - Wiring des actions mobile dashboard ("Photo de facture" + "Saisir") qui
    n'avaient pas d'onClick — branchés sur /factures/import et le manual
    dialog.

Check-in :
  - "Relancer maintenant" sur la fiche facture : actif pour pending et
    awaiting_user_confirmation. Délègue à inappRespondPending → schedule
    relances + status → in_relance + record activity.
  - InAppCheckinModal : drop le sessionStorage `rubis.checkin.dismissed`.
    Au refocus de l'onglet, dismissed reset à false → la modale re-pop
    si pending non vide. TanStack Query refetch sur focus en bonus.

Plans :
  - PlanCard : ajout d'un mb-6 sur la liste des steps pour aérer le
    divider du footer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 14:23:31 +02:00
parent 6c3b5e36b9
commit 52e78b66e9
12 changed files with 362 additions and 170 deletions

View File

@ -1,5 +1,13 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import {
Check,
AlertCircle,
ArrowRight,
FileText,
Calendar,
X,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -11,64 +19,71 @@ import {
import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format"; import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
const SESSION_DISMISS_KEY = "rubis.checkin.dismissed";
/** /**
* Modale qui se déclenche au login si l'org a des factures en * Modale qui se déclenche quand l'org a des factures en
* `awaiting_user_confirmation`. Pour chacune, l'user répond directement : * `awaiting_user_confirmation`. Pour chacune, l'user répond directement :
* - "Oui, payée" mark paid + cancel relances * - "Oui, payée" mark paid + cancel relances
* - "Non, impayée" schedule relances + status in_relance * - "Non, impayée" schedule relances + status in_relance
* - "Plus tard" on passe à la suivante, on ne touche pas la facture * - "Plus tard" skip session-only, on passe à la suivante
*
* UX :
* - Mobile : bottom-sheet qui slide up depuis le bas, full-width avec
* safe-area-inset-bottom, coins arrondis en haut, contenu
* scrollable. Plus de pouce-friendly.
* - Desktop : modale centrée classique, max 540px.
* *
* Stratégie de queue : on s'appuie sur le serveur comme source de vérité. * Stratégie de queue : on s'appuie sur le serveur comme source de vérité.
* Après chaque réponse, l'invoice quitte le statut `awaiting_user_confirmation` * Après chaque réponse, l'invoice quitte le statut `awaiting_user_confirmation`
* et disparaît du refetch. On affiche **toujours queue[0]**, donc la * et disparaît du refetch. On affiche **toujours queue[0]**, donc la
* suivante remonte naturellement à la position 0 pas de cursor à gérer. * suivante remonte naturellement à la position 0 pas de cursor à gérer.
* *
* Pour "Plus tard" (skip), on garde un set local d'IDs ignorés cette * Stratégie de re-pop :
* session, qu'on filtre côté client (le serveur les retournera toujours * - X (close) ferme pour MAINTENANT seulement
* tant qu'elles sont awaiting_user_confirmation). * - Refocus / visible reset le dismiss si pending non vide après
* refetch automatique TanStack Query, la modale
* re-pop pour rappeler à l'user de répondre.
* - "Plus tard" persist en mémoire le temps de la session on ne
* re-spame pas la même facture toutes les 5 minutes.
*/ */
export function InAppCheckinModal() { export function InAppCheckinModal() {
const { data: pending = [], isLoading } = usePendingCheckins(); const { data: pending = [], isLoading } = usePendingCheckins();
const paidMutation = useCheckinPaid(); const paidMutation = useCheckinPaid();
const stillPendingMutation = useCheckinStillPending(); const stillPendingMutation = useCheckinStillPending();
// IDs ignorés cette session (skip "Plus tard"). Persiste en mémoire
// pendant la vie du composant, perdu au refresh — ce qui est le but :
// l'user retombe dessus au prochain login.
const [skipped, setSkipped] = useState<Set<string>>(new Set()); const [skipped, setSkipped] = useState<Set<string>>(new Set());
// dismissed = "fermé maintenant" (X). Pas persistant — au refocus de
// la fenêtre, on reset à false pour redonner sa chance à la modale.
const [dismissed, setDismissed] = useState(false);
// sessionStorage flag — true = l'user a explicitement fermé (X), on // Reset le dismiss au refocus de l'onglet ou au retour de visibilité.
// ne ré-ouvre pas tant qu'il ne reload pas l'onglet. // TanStack Query refetch déjà sur focus (par défaut) → si pending
const [dismissed, setDismissed] = useState<boolean>(() => { // change pendant qu'on était sur un autre onglet, la modale reflète.
if (typeof window === "undefined") return false; useEffect(() => {
return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1"; const reset = () => setDismissed(false);
}); const onVisibility = () => {
if (document.visibilityState === "visible") reset();
};
window.addEventListener("focus", reset);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("focus", reset);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Queue = pending serveur, moins les skippés locaux. Ordre serveur
// (échéance asc) préservé.
const queue = useMemo<PendingCheckinInvoice[]>( const queue = useMemo<PendingCheckinInvoice[]>(
() => pending.filter((p) => !skipped.has(p.id)), () => pending.filter((p) => !skipped.has(p.id)),
[pending, skipped], [pending, skipped],
); );
const current = queue[0]; const current = queue[0];
const totalSeen = pending.length; // utilisé pour le compteur "X / Y" const totalSeen = pending.length;
const positionLeft = queue.length; const positionLeft = queue.length;
const shouldOpen = !isLoading && !dismissed && queue.length > 0; const shouldOpen = !isLoading && !dismissed && queue.length > 0;
const handleClose = () => { const handleClose = () => {
sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
setDismissed(true); setDismissed(true);
}; };
@ -77,8 +92,6 @@ export function InAppCheckinModal() {
paidMutation.mutate(current.id, { paidMutation.mutate(current.id, {
onSuccess: () => { onSuccess: () => {
toast.success(`${current.numero} marquée encaissée. + 1 rubis.`); toast.success(`${current.numero} marquée encaissée. + 1 rubis.`);
// Le refetch va retirer cette invoice de pending — current devient
// automatiquement la suivante (queue[0]).
}, },
onError: () => onError: () =>
toast.error("Impossible de marquer la facture. Réessayez."), toast.error("Impossible de marquer la facture. Réessayez."),
@ -109,34 +122,84 @@ export function InAppCheckinModal() {
const isPending = const isPending =
paidMutation.isPending || stillPendingMutation.isPending; paidMutation.isPending || stillPendingMutation.isPending;
// Position courante = totalSeen - positionLeft + 1, pour avoir "1/3, 2/3…"
// même si la queue rétrécit après chaque réponse.
const cursorLabel = `${totalSeen - positionLeft + 1} / ${totalSeen}`; const cursorLabel = `${totalSeen - positionLeft + 1} / ${totalSeen}`;
const remaining = positionLeft - 1; const remaining = positionLeft - 1;
return ( return (
<Dialog <DialogPrimitive.Root
open={shouldOpen} open={shouldOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) handleClose(); if (!open) handleClose();
}} }}
> >
<DialogContent maxWidth={520}> <DialogPrimitive.Portal>
<DialogHeader> <DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-ink/35 backdrop-blur-[2px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed z-50 bg-cream shadow-card overflow-y-auto",
// — Mobile : bottom-sheet
"bottom-0 inset-x-0 max-h-[92vh] rounded-t-[20px] border-t border-line",
"px-5 pt-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]",
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom",
"data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom",
// — Desktop : modale centrée classique
"sm:bottom-auto sm:inset-x-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
"sm:w-[calc(100vw-2rem)] sm:max-w-[520px] sm:max-h-[90vh]",
"sm:rounded-card sm:border sm:border-line sm:p-7",
"sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95",
"sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95",
)}
>
{/* Drag handle visuel — exclusivement mobile, signal "tu peux fermer". */}
<div
aria-hidden="true"
className="sm:hidden mx-auto mb-4 h-1 w-10 rounded-full bg-ink/15"
/>
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold"> <p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Confirmation · {cursorLabel} Confirmation · {cursorLabel}
</p> </p>
<DialogTitle className="mt-1"> <DialogPrimitive.Title
Avez-vous é <em className="text-rubis not-italic">payé</em> sur cette facture ? className={cn(
</DialogTitle> "mt-1 font-display font-semibold tracking-[-0.018em] text-ink",
<DialogDescription className="mt-1.5"> "text-[19px] leading-tight",
)}
>
Avez-vous é{" "}
<em className="text-rubis not-italic">payé</em> sur cette facture ?
</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1.5 text-[13px] text-ink-3 leading-relaxed">
Aucune relance ne part sans votre validation. Si la facture est Aucune relance ne part sans votre validation. Si la facture est
réglée, on évite l'email inutile et on encaisse +1 rubis. réglée, on évite l'email inutile et on encaisse +1 rubis.
</DialogDescription> </DialogPrimitive.Description>
</DialogHeader> </div>
<DialogPrimitive.Close
className={cn(
"shrink-0 inline-flex size-8 items-center justify-center cursor-pointer",
"rounded-default text-ink-3 hover:bg-cream-2 hover:text-ink",
"transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label="Fermer"
>
<X size={16} aria-hidden="true" />
</DialogPrimitive.Close>
</div>
{/* Card facture */}
<div className="mt-5">
<InvoiceCard invoice={current} /> <InvoiceCard invoice={current} />
</div>
{/* Actions principales */}
<div className="mt-5 flex flex-col gap-2.5"> <div className="mt-5 flex flex-col gap-2.5">
<Button <Button
size="md" size="md"
@ -162,67 +225,78 @@ export function InAppCheckinModal() {
</Button> </Button>
</div> </div>
<div className="mt-4 flex items-center justify-between"> {/* Footer : Plus tard + compteur restant */}
<div className="mt-4 flex items-center justify-between gap-3">
<button <button
type="button" type="button"
onClick={onSkip} onClick={onSkip}
disabled={isPending} disabled={isPending}
className={cn( className={cn(
"text-[12.5px] text-ink-3 hover:text-rubis underline-offset-4 hover:underline cursor-pointer", // Tap target ≥ 44pt — px-3 py-2.5 = 40-44px height
"inline-flex items-center px-3 py-2.5 -ml-3 rounded-default cursor-pointer",
"text-[13px] text-ink-3 hover:text-rubis transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
"disabled:opacity-50 disabled:cursor-not-allowed", "disabled:opacity-50 disabled:cursor-not-allowed",
)} )}
> >
Plus tard passer à la suivante Plus tard
</button> </button>
{remaining > 0 && ( {remaining > 0 && (
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1"> <p className="text-[11.5px] text-ink-3 italic flex items-center gap-1 shrink-0">
<ArrowRight size={11} aria-hidden="true" /> <ArrowRight size={11} aria-hidden="true" />
{remaining} autre{remaining > 1 ? "s" : ""} après {remaining} autre{remaining > 1 ? "s" : ""} après
</p> </p>
)} )}
</div> </div>
</DialogContent> </DialogPrimitive.Content>
</Dialog> </DialogPrimitive.Portal>
</DialogPrimitive.Root>
); );
} }
/** Petite fiche récap de la facture concernée. */ /**
* Fiche récap de la facture adaptée mobile.
* Au lieu de flex-row amount/date qui squashe à 320px, on stacke
* verticalement et on aligne tout à gauche pour la lisibilité pouce-only.
*/
function InvoiceCard({ invoice }: { invoice: PendingCheckinInvoice }) { function InvoiceCard({ invoice }: { invoice: PendingCheckinInvoice }) {
const isLate = isOverdue(invoice.dueDate); const isLate = isOverdue(invoice.dueDate);
const dueLabel = formatDueDelta(invoice.dueDate); const dueLabel = formatDueDelta(invoice.dueDate);
return ( return (
<div className="rounded-card border border-line bg-white px-4 py-3.5"> <div className="rounded-card border border-line bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3 mb-3"> {/* Header — numéro + client */}
<div className="min-w-0 flex-1"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-0.5"> <FileText size={14} className="text-ink-3 shrink-0" aria-hidden="true" />
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" /> <p className="font-display text-[14.5px] font-semibold tracking-tight text-ink truncate">
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
{invoice.numero} {invoice.numero}
</p> </p>
</div> </div>
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p> <p className="mt-0.5 text-[12.5px] text-ink-2 truncate">
</div> {invoice.clientName}
</div> </p>
<div className="flex items-end justify-between gap-3">
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink"> {/* Montant XL en feature */}
<p className="mt-3 font-display text-[26px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)} {formatEuros(invoice.amountTtcCents)}
</p> </p>
<div className="text-right">
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums"> {/* Date + retard, sur la même ligne mais avec wrap propre */}
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[12px]">
<span className="inline-flex items-center gap-1 text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" /> <Calendar size={11} aria-hidden="true" />
<span>échue le {formatDate(invoice.dueDate)}</span> échue le {formatDate(invoice.dueDate)}
</div> </span>
<p <span
className={cn( className={cn(
"mt-0.5 text-[11.5px] font-medium tabular-nums", "font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3", isLate ? "text-rubis-deep" : "text-ink-3",
)} )}
> >
{dueLabel} ({dueLabel})
</p> </span>
</div>
</div> </div>
{invoice.planName && ( {invoice.planName && (
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3"> <p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong> Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>

View File

@ -26,11 +26,28 @@ type KpiCardProps = {
export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) { export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) {
return ( return (
<Card padding="md" className={cn("min-w-0", className)}> <Card
padding="none"
className={cn(
// Padding plus serré sur mobile (sinon les chiffres TTC longs débordent
// sur les cards 2-cols à 375px).
"min-w-0 p-4 lg:p-7",
className,
)}
>
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3"> <p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label} {label}
</p> </p>
<p className="mt-2 font-display text-[28px] font-bold leading-none tracking-[-0.018em] text-ink tabular-nums"> <p
className={cn(
"mt-2 font-display font-bold leading-none tracking-[-0.018em] text-ink tabular-nums",
// 22px sur mobile, 28px à partir de lg : "401 240 €" tient sur 1 ligne
// dans les cards 2-cols même sur petit écran.
"text-[22px] lg:text-[28px]",
// Empêche le débordement quand la string est très longue.
"truncate",
)}
>
{value} {value}
</p> </p>
{delta && ( {delta && (

View File

@ -73,7 +73,10 @@ export function DemoClock() {
<> <>
<div <div
className={cn( className={cn(
"fixed top-4 right-4 z-30 w-[300px]", // Mobile : full-width avec marges symétriques.
// Desktop : 300px aligné top-right.
"fixed top-4 inset-x-4 z-30",
"sm:left-auto sm:right-4 sm:inset-x-auto sm:w-[300px]",
"rounded-card border border-rubis-glow bg-white shadow-card", "rounded-card border border-rubis-glow bg-white shadow-card",
"px-4 py-3", "px-4 py-3",
)} )}

View File

@ -131,20 +131,31 @@ export function DemoEmailSlide({
type="button" type="button"
aria-label="Fermer" aria-label="Fermer"
onClick={onContinue} onClick={onContinue}
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px] cursor-default" className="fixed inset-0 z-40 bg-ink/35 backdrop-blur-[2px] cursor-default"
/> />
<aside <aside
role="dialog" role="dialog"
aria-label="Émission Rubis pendant la démo" aria-label="Émission Rubis pendant la démo"
className={cn( className={cn(
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]", "fixed z-50 bg-cream shadow-card flex flex-col",
"bg-cream border-l border-line shadow-card flex flex-col", // — Mobile : bottom-sheet
"animate-in slide-in-from-right duration-200", "bottom-0 inset-x-0 max-h-[92vh] rounded-t-[20px] border-t border-line",
"animate-in slide-in-from-bottom duration-200",
// — Desktop : slide-over droit
"sm:bottom-auto sm:inset-x-auto sm:top-0 sm:right-0 sm:h-screen sm:max-h-none",
"sm:w-full sm:max-w-[520px] sm:rounded-none sm:border-l sm:border-t-0",
"sm:slide-in-from-right",
)} )}
> >
{/* Drag handle visuel — mobile only, signal "tu peux fermer". */}
<div
aria-hidden="true"
className="sm:hidden mx-auto mt-2.5 mb-1 h-1 w-10 rounded-full bg-ink/15 shrink-0"
/>
{/* Header — titre adapté à l'event */} {/* Header — titre adapté à l'event */}
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-4"> <div className="flex items-center gap-3 border-b border-line bg-white px-5 py-3.5 shrink-0">
<span <span
className={cn( className={cn(
"shrink-0 size-9 flex items-center justify-center rounded-full", "shrink-0 size-9 flex items-center justify-center rounded-full",
@ -164,17 +175,30 @@ export function DemoEmailSlide({
<button <button
type="button" type="button"
onClick={onContinue} onClick={onContinue}
className="size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2" className={cn(
"size-8 flex items-center justify-center rounded-default cursor-pointer",
"text-ink-3 hover:bg-cream-2 hover:text-ink transition-colors",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label="Fermer" aria-label="Fermer"
> >
<X size={14} /> <X size={16} />
</button> </button>
</div> </div>
{/* Body — change selon l'étape */} {/* Body change selon l'étape. Padding-bottom safe-area quand pas
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5"> de footer (étape "ask"), sinon le footer s'en occupe. */}
{/* Card facture — toujours visible : contexte + lien vers la fiche */} <div
<InvoiceCard invoice={invoice ?? null} fallbackNumero={event.invoiceNumero} onNavigate={onContinue} /> className={cn(
"flex-1 overflow-y-auto px-5 py-5 space-y-5",
step === "ask" && "pb-[max(1.25rem,env(safe-area-inset-bottom))]",
)}
>
<InvoiceCard
invoice={invoice ?? null}
fallbackNumero={event.invoiceNumero}
onNavigate={onContinue}
/>
{step === "ask" && ( {step === "ask" && (
<AskStep <AskStep
@ -188,16 +212,23 @@ export function DemoEmailSlide({
{step === "paid" && <PaidStep invoiceNumero={event.invoiceNumero} />} {step === "paid" && <PaidStep invoiceNumero={event.invoiceNumero} />}
</div> </div>
{/* Footer — uniquement à l'étape email/paid (l'étape ask a ses propres boutons) */} {/* Footer uniquement à l'étape email/paid (l'étape ask a ses propres boutons).
Padding inférieur safe-area pour les iPhones notchés. */}
{step !== "ask" && ( {step !== "ask" && (
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between"> <div
<p className="text-[11.5px] text-ink-3 italic"> className={cn(
"border-t border-line bg-white px-5 py-4 shrink-0",
"flex items-center justify-between gap-3",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<p className="text-[11.5px] text-ink-3 italic min-w-0">
{remaining > 0 {remaining > 0
? `${remaining} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file` ? `${remaining} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file`
: "Cliquez pour reprendre l'horloge"} : "Cliquez pour reprendre l'horloge"}
</p> </p>
<Button size="sm" onClick={onContinue}> <Button size="sm" onClick={onContinue} className="shrink-0">
Continuer la démo <ArrowRight size={14} /> Continuer <ArrowRight size={14} />
</Button> </Button>
</div> </div>
)} )}

View File

@ -1,3 +1,4 @@
import { Link } from "@tanstack/react-router";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
@ -40,10 +41,18 @@ export function AppTopbar({ title, subtitle, actions }: AppTopbarProps) {
)} )}
> >
<div className="flex items-center justify-between gap-4 px-5 py-4 lg:px-8 lg:py-5"> <div className="flex items-center justify-between gap-4 px-5 py-4 lg:px-8 lg:py-5">
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting. */} {/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting.
<div className="lg:hidden"> Le brand est cliquable retour au dashboard. */}
<Link
to="/"
aria-label="Retour au dashboard"
className={cn(
"lg:hidden rounded-default cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)}
>
<Brand /> <Brand />
</div> </Link>
<div className="hidden lg:block min-w-0"> <div className="hidden lg:block min-w-0">
<h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight"> <h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight">
{greeting} {greeting}

View File

@ -1,9 +1,15 @@
import { Home, FileText, ListChecks, Settings } from "lucide-react"; import { Home, FileText, ListChecks, Plus } from "lucide-react";
import { NavLink } from "./NavLink"; import { NavLink } from "./NavLink";
/** /**
* Tab bar mobile fixed bottom, 4 entrées max. * Tab bar mobile fixed bottom, 4 entrées max.
*
* Pas de "Clients" : on y accède depuis une facture (cf. wireframe 4.3). * Pas de "Clients" : on y accède depuis une facture (cf. wireframe 4.3).
* Pas de "Réglages" non plus : disponible via l'avatar topbar (UserMenu).
*
* Slot 4 = "+ Nouvelle facture" (= /factures/import) l'action la plus
* fréquente sur mobile (photo + drop), accessible en 1 tap depuis n'importe
* dans l'app.
*/ */
export function MobileTabBar() { export function MobileTabBar() {
return ( return (
@ -26,10 +32,10 @@ export function MobileTabBar() {
label="Plans" label="Plans"
/> />
<NavLink <NavLink
to="/parametres" to="/factures/import"
variant="tab-bar" variant="tab-bar"
icon={<Settings size={19} />} icon={<Plus size={19} />}
label="Réglages" label="Nouvelle"
/> />
</div> </div>
</nav> </nav>

View File

@ -82,7 +82,7 @@ export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps)
</p> </p>
)} )}
<ul className="mt-4 flex flex-col gap-1.5 text-[12.5px] text-ink-2"> <ul className="mt-4 mb-6 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
{plan.steps.slice(0, 3).map((step) => ( {plan.steps.slice(0, 3).map((step) => (
<li key={step.id} className="flex items-baseline gap-2"> <li key={step.id} className="flex items-baseline gap-2">
<span <span

View File

@ -18,7 +18,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
className={cn( className={cn(
// Base // Base
"block w-full rounded-default border border-line bg-white px-3.5 py-3", "block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3", // 16px MIN sur mobile pour empêcher iOS Safari de zoomer au focus
// (≥16px = pas de zoom). On revient à 15px sur desktop pour densité.
"font-sans text-base lg:text-[15px] text-ink placeholder:text-ink-3",
// Transitions // Transitions
"transition-[border-color,box-shadow] duration-150", "transition-[border-color,box-shadow] duration-150",
// Focus // Focus

View File

@ -15,7 +15,8 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
rows={rows} rows={rows}
className={cn( className={cn(
"block w-full rounded-default border border-line bg-white px-3.5 py-3", "block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3", // 16px MIN sur mobile pour empêcher iOS Safari de zoomer au focus.
"font-sans text-base lg:text-[15px] text-ink placeholder:text-ink-3",
"transition-[border-color,box-shadow] duration-150 resize-none", "transition-[border-color,box-shadow] duration-150 resize-none",
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow", "focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3", "disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",

View File

@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@ -17,6 +18,7 @@ import {
type InvoiceListItem, type InvoiceListItem,
} from "@/components/factures/InvoiceTable"; } from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList"; import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
import { Button } from "@/components/ui/Button";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog"; import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices"; import { uploadInvoiceFiles } from "@/lib/invoices";
@ -172,7 +174,8 @@ function FacturesPage() {
onDragOver={onPageDragOver} onDragOver={onPageDragOver}
onDrop={onPageDrop} onDrop={onPageDrop}
> >
<div> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink"> <h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Factures{" "} Factures{" "}
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle"> <span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
@ -184,6 +187,14 @@ function FacturesPage() {
pour voir la timeline. pour voir la timeline.
</p> </p>
</div> </div>
<Button size="sm" asChild className="shrink-0">
<Link to="/factures/import">
<Plus size={14} aria-hidden="true" />
<span className="hidden sm:inline">Nouvelle facture</span>
<span className="sm:hidden">Nouvelle</span>
</Link>
</Button>
</div>
<FilterChips <FilterChips
options={filterOptions} options={filterOptions}

View File

@ -7,6 +7,7 @@ import { z } from "zod";
import type { Client, Invoice, Plan } from "@rubis/shared"; import type { Client, Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useCheckinStillPending } from "@/lib/checkin";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format"; import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
@ -70,6 +71,11 @@ function InvoiceDetailPage() {
}, },
}); });
// "Relancer maintenant" : court-circuite le check-in, programme les
// relances + bascule l'invoice en `in_relance` immédiatement. Réutilise
// l'endpoint inappRespondPending qui fait exactement ça.
const launchRelanceMutation = useCheckinStillPending();
useEffect(() => { useEffect(() => {
const checkin = search.checkin; const checkin = search.checkin;
if (!checkin) return; if (!checkin) return;
@ -165,7 +171,30 @@ function InvoiceDetailPage() {
> >
<Check size={14} aria-hidden="true" /> Marquer encaissée <Check size={14} aria-hidden="true" /> Marquer encaissée
</Button> </Button>
<Button size="sm" disabled> {/* "Relancer maintenant" : actif pour pending et awaiting_user_confirmation
court-circuite le check-in et déclenche immédiatement le plan.
Désactivé pour `in_relance` car le plan tourne déjà. */}
<Button
size="sm"
loading={launchRelanceMutation.isPending}
disabled={
invoice.status !== "pending" &&
invoice.status !== "awaiting_user_confirmation"
}
onClick={() =>
launchRelanceMutation.mutate(invoice.id, {
onSuccess: () => {
toast.success(
`Relances activées pour ${invoice.numero}.`,
);
},
onError: () =>
toast.error(
"Impossible de programmer les relances. Réessayez.",
),
})
}
>
<Send size={14} aria-hidden="true" /> Relancer maintenant <Send size={14} aria-hidden="true" /> Relancer maintenant
</Button> </Button>
</div> </div>

View File

@ -11,6 +11,7 @@ import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm"; import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary"; import { GLOSSARY } from "@/lib/glossary";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { RubisHero } from "@/components/dashboard/RubisHero"; import { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard"; import { KpiCard } from "@/components/dashboard/KpiCard";
import { import {
@ -72,6 +73,7 @@ export const Route = createFileRoute("/_app/")({
}); });
function DashboardPage() { function DashboardPage() {
const manual = useManualInvoice();
const { data: kpis } = useQuery({ const { data: kpis } = useQuery({
queryKey: queryKeys.dashboard.kpis(), queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"), queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
@ -96,10 +98,17 @@ function DashboardPage() {
<div className="flex flex-col gap-6 lg:gap-7"> <div className="flex flex-col gap-6 lg:gap-7">
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */} {/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
<div className="flex gap-2 lg:hidden"> <div className="flex gap-2 lg:hidden">
<Button size="sm" className="flex-1"> <Button size="sm" className="flex-1" asChild>
<Link to="/factures/import">
<Camera size={15} aria-hidden="true" /> Photo de facture <Camera size={15} aria-hidden="true" /> Photo de facture
</Link>
</Button> </Button>
<Button size="sm" variant="secondary" className="flex-1"> <Button
size="sm"
variant="secondary"
className="flex-1"
onClick={manual.open}
>
<Plus size={15} aria-hidden="true" /> Saisir <Plus size={15} aria-hidden="true" /> Saisir
</Button> </Button>
</div> </div>