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 { 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,120 +122,181 @@ 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
|
||||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
className={cn(
|
||||||
Confirmation · {cursorLabel}
|
"fixed inset-0 z-50 bg-ink/35 backdrop-blur-[2px]",
|
||||||
</p>
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
<DialogTitle className="mt-1">
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</DialogContent>
|
<DialogPrimitive.Content
|
||||||
</Dialog>
|
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 }) {
|
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>
|
|
||||||
</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)}
|
|
||||||
</p>
|
</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>
|
</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 && (
|
{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>
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
* où 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,17 +174,26 @@ function FacturesPage() {
|
|||||||
onDragOver={onPageDragOver}
|
onDragOver={onPageDragOver}
|
||||||
onDrop={onPageDrop}
|
onDrop={onPageDrop}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
<div className="min-w-0 flex-1">
|
||||||
Factures{" "}
|
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||||
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
|
Factures{" "}
|
||||||
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
|
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
|
||||||
</span>
|
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
|
||||||
</h1>
|
</span>
|
||||||
<p className="mt-1 text-[13.5px] text-ink-3">
|
</h1>
|
||||||
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
|
<p className="mt-1 text-[13.5px] text-ink-3">
|
||||||
pour voir la timeline.
|
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<FilterChips
|
<FilterChips
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
<Camera size={15} aria-hidden="true" /> Photo de facture
|
<Link to="/factures/import">
|
||||||
|
<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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user