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:
parent
6c3b5e36b9
commit
52e78b66e9
@ -1,5 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Check,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Calendar,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@ -11,64 +19,71 @@ import {
|
||||
import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/Dialog";
|
||||
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 :
|
||||
* - "Oui, payée" → mark paid + cancel relances
|
||||
* - "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é.
|
||||
* 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
|
||||
* 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
|
||||
* session, qu'on filtre côté client (le serveur les retournera toujours
|
||||
* tant qu'elles sont awaiting_user_confirmation).
|
||||
* Stratégie de re-pop :
|
||||
* - X (close) → ferme pour MAINTENANT seulement
|
||||
* - 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() {
|
||||
const { data: pending = [], isLoading } = usePendingCheckins();
|
||||
const paidMutation = useCheckinPaid();
|
||||
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());
|
||||
// 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
|
||||
// ne ré-ouvre pas tant qu'il ne reload pas l'onglet.
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1";
|
||||
});
|
||||
// Reset le dismiss au refocus de l'onglet ou au retour de visibilité.
|
||||
// TanStack Query refetch déjà sur focus (par défaut) → si pending
|
||||
// change pendant qu'on était sur un autre onglet, la modale reflète.
|
||||
useEffect(() => {
|
||||
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[]>(
|
||||
() => pending.filter((p) => !skipped.has(p.id)),
|
||||
[pending, skipped],
|
||||
);
|
||||
const current = queue[0];
|
||||
const totalSeen = pending.length; // utilisé pour le compteur "X / Y"
|
||||
const totalSeen = pending.length;
|
||||
const positionLeft = queue.length;
|
||||
|
||||
const shouldOpen = !isLoading && !dismissed && queue.length > 0;
|
||||
|
||||
const handleClose = () => {
|
||||
sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
@ -77,8 +92,6 @@ export function InAppCheckinModal() {
|
||||
paidMutation.mutate(current.id, {
|
||||
onSuccess: () => {
|
||||
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: () =>
|
||||
toast.error("Impossible de marquer la facture. Réessayez."),
|
||||
@ -109,120 +122,181 @@ export function InAppCheckinModal() {
|
||||
|
||||
const 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 remaining = positionLeft - 1;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<DialogPrimitive.Root
|
||||
open={shouldOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent maxWidth={520}>
|
||||
<DialogHeader>
|
||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||
Confirmation · {cursorLabel}
|
||||
</p>
|
||||
<DialogTitle className="mt-1">
|
||||
Avez-vous été <em className="text-rubis not-italic">payé</em> sur cette facture ?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1.5">
|
||||
Aucune relance ne part sans votre validation. Si la facture est
|
||||
réglée, on évite l'email inutile et on encaisse +1 rubis.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<InvoiceCard invoice={current} />
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2.5">
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
loading={paidMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onPaid}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Check size={15} aria-hidden="true" />
|
||||
Oui — la facture est payée
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
loading={stillPendingMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onStillPending}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<AlertCircle size={15} aria-hidden="true" />
|
||||
Non — toujours en attente, lance les relances
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"text-[12.5px] text-ink-3 hover:text-rubis underline-offset-4 hover:underline cursor-pointer",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
Plus tard — passer à la suivante
|
||||
</button>
|
||||
{remaining > 0 && (
|
||||
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1">
|
||||
<ArrowRight size={11} aria-hidden="true" />
|
||||
{remaining} autre{remaining > 1 ? "s" : ""} après
|
||||
</p>
|
||||
<DialogPrimitive.Portal>
|
||||
<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",
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
/>
|
||||
<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">
|
||||
Confirmation · {cursorLabel}
|
||||
</p>
|
||||
<DialogPrimitive.Title
|
||||
className={cn(
|
||||
"mt-1 font-display font-semibold tracking-[-0.018em] text-ink",
|
||||
"text-[19px] leading-tight",
|
||||
)}
|
||||
>
|
||||
Avez-vous été{" "}
|
||||
<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
|
||||
réglée, on évite l'email inutile et on encaisse +1 rubis.
|
||||
</DialogPrimitive.Description>
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
{/* Actions principales */}
|
||||
<div className="mt-5 flex flex-col gap-2.5">
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
loading={paidMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onPaid}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Check size={15} aria-hidden="true" />
|
||||
Oui — la facture est payée
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
loading={stillPendingMutation.isPending}
|
||||
disabled={isPending}
|
||||
onClick={onStillPending}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<AlertCircle size={15} aria-hidden="true" />
|
||||
Non — toujours en attente, lance les relances
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer : Plus tard + compteur restant */}
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
// 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",
|
||||
)}
|
||||
>
|
||||
Plus tard
|
||||
</button>
|
||||
{remaining > 0 && (
|
||||
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1 shrink-0">
|
||||
<ArrowRight size={11} aria-hidden="true" />
|
||||
{remaining} autre{remaining > 1 ? "s" : ""} après
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</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 }) {
|
||||
const isLate = isOverdue(invoice.dueDate);
|
||||
const dueLabel = formatDueDelta(invoice.dueDate);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-line bg-white px-4 py-3.5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
|
||||
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
|
||||
{invoice.numero}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
<div className="rounded-card border border-line bg-white px-4 py-4">
|
||||
{/* Header — numéro + client */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-ink-3 shrink-0" aria-hidden="true" />
|
||||
<p className="font-display text-[14.5px] font-semibold tracking-tight text-ink truncate">
|
||||
{invoice.numero}
|
||||
</p>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
|
||||
<Calendar size={11} aria-hidden="true" />
|
||||
<span>échue le {formatDate(invoice.dueDate)}</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 text-[11.5px] font-medium tabular-nums",
|
||||
isLate ? "text-rubis-deep" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{dueLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-ink-2 truncate">
|
||||
{invoice.clientName}
|
||||
</p>
|
||||
|
||||
{/* Montant XL en feature */}
|
||||
<p className="mt-3 font-display text-[26px] font-bold tabular-nums leading-none text-ink">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
</p>
|
||||
|
||||
{/* 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" />
|
||||
échue le {formatDate(invoice.dueDate)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
isLate ? "text-rubis-deep" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
({dueLabel})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{invoice.planName && (
|
||||
<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>
|
||||
|
||||
@ -26,11 +26,28 @@ type KpiCardProps = {
|
||||
|
||||
export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) {
|
||||
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">
|
||||
{label}
|
||||
</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}
|
||||
</p>
|
||||
{delta && (
|
||||
|
||||
@ -73,7 +73,10 @@ export function DemoClock() {
|
||||
<>
|
||||
<div
|
||||
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",
|
||||
"px-4 py-3",
|
||||
)}
|
||||
|
||||
@ -131,20 +131,31 @@ export function DemoEmailSlide({
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
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
|
||||
role="dialog"
|
||||
aria-label="Émission Rubis pendant la démo"
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]",
|
||||
"bg-cream border-l border-line shadow-card flex flex-col",
|
||||
"animate-in slide-in-from-right duration-200",
|
||||
"fixed z-50 bg-cream shadow-card flex flex-col",
|
||||
// — Mobile : bottom-sheet
|
||||
"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 */}
|
||||
<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
|
||||
className={cn(
|
||||
"shrink-0 size-9 flex items-center justify-center rounded-full",
|
||||
@ -164,17 +175,30 @@ export function DemoEmailSlide({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body — change selon l'étape */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
|
||||
{/* Card facture — toujours visible : contexte + lien vers la fiche */}
|
||||
<InvoiceCard invoice={invoice ?? null} fallbackNumero={event.invoiceNumero} onNavigate={onContinue} />
|
||||
{/* Body — change selon l'étape. Padding-bottom safe-area quand pas
|
||||
de footer (étape "ask"), sinon le footer s'en occupe. */}
|
||||
<div
|
||||
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" && (
|
||||
<AskStep
|
||||
@ -188,16 +212,23 @@ export function DemoEmailSlide({
|
||||
{step === "paid" && <PaidStep invoiceNumero={event.invoiceNumero} />}
|
||||
</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" && (
|
||||
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
|
||||
<p className="text-[11.5px] text-ink-3 italic">
|
||||
<div
|
||||
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} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file`
|
||||
: "Cliquez pour reprendre l'horloge"}
|
||||
</p>
|
||||
<Button size="sm" onClick={onContinue}>
|
||||
Continuer la démo <ArrowRight size={14} />
|
||||
<Button size="sm" onClick={onContinue} className="shrink-0">
|
||||
Continuer <ArrowRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
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">
|
||||
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting. */}
|
||||
<div className="lg:hidden">
|
||||
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting.
|
||||
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 />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="hidden lg:block min-w-0">
|
||||
<h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight">
|
||||
{greeting}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { Home, FileText, ListChecks, Settings } from "lucide-react";
|
||||
import { Home, FileText, ListChecks, Plus } from "lucide-react";
|
||||
import { NavLink } from "./NavLink";
|
||||
|
||||
/**
|
||||
* 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 "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
|
||||
* où dans l'app.
|
||||
*/
|
||||
export function MobileTabBar() {
|
||||
return (
|
||||
@ -26,10 +32,10 @@ export function MobileTabBar() {
|
||||
label="Plans"
|
||||
/>
|
||||
<NavLink
|
||||
to="/parametres"
|
||||
to="/factures/import"
|
||||
variant="tab-bar"
|
||||
icon={<Settings size={19} />}
|
||||
label="Réglages"
|
||||
icon={<Plus size={19} />}
|
||||
label="Nouvelle"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -82,7 +82,7 @@ export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps)
|
||||
</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) => (
|
||||
<li key={step.id} className="flex items-baseline gap-2">
|
||||
<span
|
||||
|
||||
@ -18,7 +18,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn(
|
||||
// Base
|
||||
"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
|
||||
"transition-[border-color,box-shadow] duration-150",
|
||||
// Focus
|
||||
|
||||
@ -15,7 +15,8 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
rows={rows}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@ -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 { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -17,6 +18,7 @@ import {
|
||||
type InvoiceListItem,
|
||||
} from "@/components/factures/InvoiceTable";
|
||||
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||
import { uploadInvoiceFiles } from "@/lib/invoices";
|
||||
@ -172,17 +174,26 @@ function FacturesPage() {
|
||||
onDragOver={onPageDragOver}
|
||||
onDrop={onPageDrop}
|
||||
>
|
||||
<div>
|
||||
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||
Factures{" "}
|
||||
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
|
||||
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-[13.5px] text-ink-3">
|
||||
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
|
||||
pour voir la timeline.
|
||||
</p>
|
||||
<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">
|
||||
Factures{" "}
|
||||
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
|
||||
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-[13.5px] text-ink-3">
|
||||
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
|
||||
pour voir la timeline.
|
||||
</p>
|
||||
</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
|
||||
|
||||
@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
|
||||
import type { Client, Invoice, Plan } from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCheckinStillPending } from "@/lib/checkin";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
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(() => {
|
||||
const checkin = search.checkin;
|
||||
if (!checkin) return;
|
||||
@ -165,7 +171,30 @@ function InvoiceDetailPage() {
|
||||
>
|
||||
<Check size={14} aria-hidden="true" /> Marquer encaissée
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,7 @@ import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
|
||||
import { GLOSSARY } from "@/lib/glossary";
|
||||
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||
import { RubisHero } from "@/components/dashboard/RubisHero";
|
||||
import { KpiCard } from "@/components/dashboard/KpiCard";
|
||||
import {
|
||||
@ -72,6 +73,7 @@ export const Route = createFileRoute("/_app/")({
|
||||
});
|
||||
|
||||
function DashboardPage() {
|
||||
const manual = useManualInvoice();
|
||||
const { data: kpis } = useQuery({
|
||||
queryKey: queryKeys.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">
|
||||
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
|
||||
<div className="flex gap-2 lg:hidden">
|
||||
<Button size="sm" className="flex-1">
|
||||
<Camera size={15} aria-hidden="true" /> Photo de facture
|
||||
<Button size="sm" className="flex-1" asChild>
|
||||
<Link to="/factures/import">
|
||||
<Camera size={15} aria-hidden="true" /> Photo de facture
|
||||
</Link>
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user