Compare commits
2 Commits
6c3b5e36b9
...
d410ae014e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d410ae014e | ||
|
|
52e78b66e9 |
@ -137,7 +137,8 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
|
||||
| `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
|
||||
| `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
|
||||
| `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) |
|
||||
| `/docs/produit.md` | Spec produit détaillée (features, flows, IN/OUT V1) |
|
||||
| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing) |
|
||||
| `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases |
|
||||
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) |
|
||||
| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) |
|
||||
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
|
||||
|
||||
@ -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>
|
||||
|
||||
418
docs/flow.md
Normal file
418
docs/flow.md
Normal file
@ -0,0 +1,418 @@
|
||||
# Flow produit — Rubis Sur l'Ongle
|
||||
|
||||
> Cette doc décrit **comment Rubis se comporte** côté user-lambda : quel
|
||||
> statut une facture prend, quand elle change d'état, qui voit quoi quand.
|
||||
> Pour la spec produit haut-niveau (cible, pricing, IN/OUT V1) → `produit.md`.
|
||||
> Pour les décisions architecturales → `decisions.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modèle mental en une phrase
|
||||
|
||||
> Une facture passe par un nombre limité d'**états bien définis**, et toute
|
||||
> transition est **soit déclenchée par l'utilisateur** (mark paid, "non
|
||||
> impayée"), **soit par le scheduler** (envoi d'un mail, fin de plan). Aucune
|
||||
> transition silencieuse n'envoie de mail au client final sans validation
|
||||
> humaine — c'est la promesse centrale de la marque.
|
||||
|
||||
---
|
||||
|
||||
## 2. Glossaire produit
|
||||
|
||||
| Terme | Définition |
|
||||
|---|---|
|
||||
| **Rubis** | Unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance que l'user n'a pas eu à faire à la main. Crédité sur l'org à chaque mark-paid. |
|
||||
| **Plan de relance** | Cadence d'emails programmés (ex. J+3, J+10, J+25) avec un ton et un contenu par étape. Une facture est associée à 0 ou 1 plan. |
|
||||
| **Étape** (`PlanStep`) | Un email programmé dans un plan. Possède : un `offsetDays` (relatif à la `dueDate`), un ton, un sujet, un body, et un flag `requiresManualValidation` (pour la mise en demeure). |
|
||||
| **Confirmation** | (Anciennement "check-in"). Mécanique cœur du produit : Rubis demande à l'user "cette facture a-t-elle été payée ?" avant d'envoyer la prochaine relance. Voir §5. |
|
||||
| **Mise en demeure** | Étape ferme du plan, avec mention LME explicite. **Toujours sous validation manuelle** via modale de confirmation, jamais auto. |
|
||||
| **DSO** | Days Sales Outstanding — délai moyen entre l'émission d'une facture et son paiement. Métrique secondaire. |
|
||||
| **LME** | Loi de Modernisation de l'Économie (2008). Plafonne les délais de paiement à 60j (ou 45j fin de mois). Encadre la rédaction des mises en demeure. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cycle de vie d'une facture
|
||||
|
||||
### 3.1 Les 6 états
|
||||
|
||||
Une facture est toujours dans **exactement un** des états suivants (`Invoice.status`) :
|
||||
|
||||
| État | Sens produit | Visible dans le filtre |
|
||||
|---|---|---|
|
||||
| `pending` | Facture **fraîchement importée**, pas encore relancée. Attente du premier check-in (qui partira à `dueDate`). | "À relancer" |
|
||||
| `awaiting_user_confirmation` | Le check-in a été envoyé à l'user, on attend qu'il réponde "payée ?" ou "toujours impayée ?". Le scheduler ne fait rien tant que l'user n'a pas répondu. | "À valider" |
|
||||
| `in_relance` | L'user a confirmé l'impayé → le plan tourne. Au moins une relance a été ou est sur le point d'être envoyée au client. | "En relance" |
|
||||
| `paid` | Facture réglée. `paidAt` populé, `+1 rubis` crédité, futures relances `cancelled`. | "Encaissées" |
|
||||
| `litigation` | Facture contestée par le client (refus, désaccord, longue impayé). Pas de relance auto, action manuelle requise (huissier, recommandé, médiation). | "Litige" |
|
||||
| `cancelled` | Facture annulée (avoir, doublon, erreur). Sortie du portefeuille actif, conservée en historique. | (pas dans les chips, accessible "Toutes") |
|
||||
|
||||
### 3.2 Diagramme de transitions
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Import / saisie │
|
||||
│ manuelle │
|
||||
└────────────┬────────────┘
|
||||
↓
|
||||
┌────────┐
|
||||
│ pending │
|
||||
└───┬─────┘
|
||||
│
|
||||
Le scheduler envoie le check-in
|
||||
à dueDate (= jour de l'échéance)
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ awaiting_user_confirmation │ ←──── état pivot
|
||||
└────┬────────────────┬────────────────┬──┘ (UI insiste ici :
|
||||
│ │ │ modale au login,
|
||||
Réponse "Oui"│ │ Réponse │ bouton fiche
|
||||
(payée) │ │ "Non" │ facture)
|
||||
↓ ↓ ↓
|
||||
┌────────┐ ┌────────────┐ (dismiss → reste
|
||||
│ paid │ │ in_relance │ en awaiting jusqu'à
|
||||
└────────┘ └─────┬──────┘ reprise par l'user)
|
||||
│
|
||||
│ scheduler envoie chaque
|
||||
│ étape à `dueDate + offset`
|
||||
│
|
||||
│ user clique "Marquer encaissée"
|
||||
│ depuis la fiche facture ↓
|
||||
↓
|
||||
┌────────┐
|
||||
│ paid │
|
||||
└────────┘
|
||||
|
||||
Transitions manuelles (par l'user, depuis la fiche facture) :
|
||||
• [tout état actif] → litigation (TODO V1.5 — UI à câbler)
|
||||
• [tout état] → cancelled (TODO V1.5 — UI à câbler)
|
||||
• paid → (pas de retour, sauf bug — il y a un endpoint mark-unpaid V2 prévu)
|
||||
```
|
||||
|
||||
### 3.3 Détails par transition
|
||||
|
||||
#### `pending → awaiting_user_confirmation`
|
||||
- **Qui déclenche** : le scheduler `CheckinTask`, automatiquement, quand `dueDate` est atteinte.
|
||||
- **Effet** : un email check-in part à l'user (pas au client) avec 2 liens *Oui (payée)* / *Non (toujours impayée)*. La `CheckinTask` est marquée `sent`.
|
||||
- **Note V1** : dans la prod actuelle, le statut DB de l'invoice reste techniquement `pending` jusqu'à ce que l'user réponde — c'est le seed démo qui force `awaiting_user_confirmation` pour pré-peupler des cas. À aligner V1.5 (le job `send_checkin_job` devrait push le statut).
|
||||
|
||||
#### `awaiting_user_confirmation → paid` (réponse "Oui")
|
||||
- **Qui déclenche** : l'user, via 4 surfaces possibles :
|
||||
1. Modale in-app au login (`InAppCheckinModal`) — bouton **Oui — la facture est payée**
|
||||
2. Lien `paid` dans l'email check-in
|
||||
3. Fiche facture — bouton **Marquer encaissée**
|
||||
4. Slide-over en mode démo (`DemoEmailSlide`)
|
||||
- **Effet en cascade** :
|
||||
- `Invoice.status = 'paid'`, `Invoice.paidAt = now`
|
||||
- `Invoice.rubisEarned += 1`, `Organization.rubisCount += 1`
|
||||
- Toutes les `RelanceTask` `scheduled` futures → `cancelled` (jobs BullMQ removed)
|
||||
- `CheckinTask.status = 'answered'`, `answer = 'paid'`, `answeredAt = now`
|
||||
- Activity event `invoice_paid`
|
||||
|
||||
#### `awaiting_user_confirmation → in_relance` (réponse "Non")
|
||||
- **Qui déclenche** : l'user, via les mêmes 4 surfaces.
|
||||
- **Effet** :
|
||||
- `Invoice.status = 'in_relance'`
|
||||
- `CheckinTask.status = 'answered'`, `answer = 'still_pending'`
|
||||
- `scheduleRelancesForInvoice()` est appelé :
|
||||
- Récupère le plan associé à la facture
|
||||
- Pour chaque step : crée une `RelanceTask` avec `sendAt = invoice.dueDate + step.offsetDays`
|
||||
- Si certains `sendAt` sont déjà passés (cas d'une facture en retard), la 1re étape éligible est calée à `now + 1min` puis les suivantes gardent l'écart prévu (cf. `catchUpAnchor` dans `relance_scheduler.ts`)
|
||||
- Enqueue chaque task dans BullMQ avec `delay = sendAt - now`
|
||||
- Activity event `relance_sent`
|
||||
|
||||
#### `in_relance → paid` (mark paid manuel)
|
||||
- **Qui déclenche** : l'user via le bouton **Marquer encaissée** sur la fiche facture (typiquement après avoir reçu un virement).
|
||||
- **Effet** : identique au "Oui" depuis check-in (mark paid + cancel relances + bonus rubis).
|
||||
|
||||
#### `in_relance → litigation` (V1.5)
|
||||
- Pas encore câblé en UI. Endpoint backend prévu : `POST /invoices/:id/litigate` qui :
|
||||
- Status → `litigation`
|
||||
- Cancel les relances futures
|
||||
- (Optionnel) génère un brouillon de mise en demeure si pas déjà envoyé
|
||||
|
||||
#### `* → cancelled` (V1.5)
|
||||
- Toute facture sortie du flux normal (annulée, doublon).
|
||||
- Pas de comptage dans le CA, pas dans les graphes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Surfaces UI — où l'user agit
|
||||
|
||||
### 4.1 Modale check-in au login (`InAppCheckinModal`)
|
||||
|
||||
**Quand** : à chaque ouverture de l'app si `pending` non vide (factures `awaiting_user_confirmation`).
|
||||
|
||||
**Comportement** :
|
||||
- Affiche **toujours `queue[0]`** — l'invoice la plus ancienne en attente
|
||||
- L'user a 4 options :
|
||||
- **Oui** → mark paid (cf. transition ci-dessus)
|
||||
- **Non** → schedule relances + status → in_relance
|
||||
- **Plus tard** → skip cette facture pour la session (set local en mémoire), passe à `queue[1]`
|
||||
- **X (close)** → ferme la modale **pour ce moment seulement**
|
||||
- Si l'user ferme et revient sur l'onglet (`focus` / `visibilitychange`), `dismissed` est reset à `false` → la modale re-pop si pending est encore non-vide. TanStack Query refetch sur focus en bonus.
|
||||
- Pas de persistance `sessionStorage` : le user qui dismiss accède toujours aux factures via `/factures?status=awaiting_user_confirmation` (chip "À valider").
|
||||
|
||||
**Mobile vs desktop** :
|
||||
- Mobile : bottom-sheet (slide-from-bottom, drag handle, safe-area-inset-bottom)
|
||||
- Desktop : modale centrée, max 520px
|
||||
|
||||
### 4.2 Email check-in
|
||||
|
||||
**Quand** : envoyé automatiquement par le scheduler à `dueDate` de chaque facture (sauf si déjà payée ou cancelled).
|
||||
|
||||
**Contenu** :
|
||||
- À : l'utilisateur Rubis (**pas le client final**)
|
||||
- Reply-to : l'utilisateur lui-même (au cas où il répondrait)
|
||||
- Sujet : "Facture F-XXXX — payée par CLIENT_NAME ?"
|
||||
- Body avec 2 liens :
|
||||
- `https://app.rubis.../api/v1/checkin/:token/paid` → respondPaid → redirect SPA `?checkin=paid`
|
||||
- `https://app.rubis.../api/v1/checkin/:token/pending` → respondPending → redirect SPA `?checkin=pending`
|
||||
- Token signé (32 bytes random base64url, hash SHA-256 stocké en DB), TTL 24h.
|
||||
|
||||
**Sécurité** :
|
||||
- Si le token est invalide / inconnu / expiré → redirect SPA `?checkin=invalid|expired`
|
||||
- Si l'invoice a été payée entre-temps → `?checkin=already_answered`
|
||||
- Pas de réponse possible 2× (idempotent côté serveur).
|
||||
|
||||
### 4.3 Fiche facture (`/factures/:id`)
|
||||
|
||||
Header avec 2 boutons d'action quand status ∈ {`pending`, `awaiting_user_confirmation`, `in_relance`} :
|
||||
|
||||
| Bouton | Visible si | Effet |
|
||||
|---|---|---|
|
||||
| **Marquer encaissée** | tout sauf paid/cancelled/litigation | Mark paid (transition vers `paid`) |
|
||||
| **Relancer maintenant** | `pending` ou `awaiting_user_confirmation` | Lance les relances (transition vers `in_relance`) |
|
||||
|
||||
Le 2e bouton est désactivé pour `in_relance` (le plan tourne déjà, pas de re-trigger possible côté UI V1).
|
||||
|
||||
Sur la timeline de la fiche, on affiche tous les events :
|
||||
- `invoice_imported` (toujours, à `issueDate`)
|
||||
- `relance_sent` (1 par relance déjà envoyée)
|
||||
- `invoice_paid` (si payée)
|
||||
|
||||
Et toutes les `RelanceTask` du plan, avec leur statut visuel :
|
||||
- **Envoyée après votre confirmation** (`task.status = 'sent'`)
|
||||
- **Confirmation avant envoi** (`task.status = 'scheduled'`) — wording uniforme, rappelle qu'aucun mail ne part sans validation
|
||||
- **Annulée — facture encaissée** (`task.status = 'cancelled'`)
|
||||
|
||||
Au-dessus de la timeline : un bandeau rassurant *"Aucune relance ne part sans votre validation. Avant chaque envoi, Rubis vous demande si la facture a été réglée — vous gardez la main."*
|
||||
|
||||
### 4.4 Slide-over démo (`DemoEmailSlide`)
|
||||
|
||||
**Quand** : en mode démo, à chaque event qui fire (relance ou check-in dû). L'horloge virtuelle est en pause tant que l'user n'a pas acquitté.
|
||||
|
||||
**Comportement** :
|
||||
- Étape 1 ("ask") : même question qu'en réel, mêmes 2 boutons Oui/Non
|
||||
- "Oui" → mark paid + écran "Encaissée"
|
||||
- "Non" → écran preview de l'email qui aurait été envoyé (pas envoyé pour de vrai en démo, capturé en BDD via `demo_captured_emails`)
|
||||
- "Continuer la démo" → reprend l'horloge
|
||||
|
||||
Mobile : bottom-sheet (idem modale check-in).
|
||||
Desktop : slide-over droit (h-screen, max 520px).
|
||||
|
||||
---
|
||||
|
||||
## 5. Le mécanisme de confirmation (deep dive)
|
||||
|
||||
### 5.1 Pourquoi cette mécanique
|
||||
|
||||
C'est la **promesse centrale** de Rubis : aucune relance ne part sans que l'user ait dit "oui, vraie impayée, lance".
|
||||
|
||||
Sans ça, le SaaS ferait peur (peur de relancer un client qui vient de payer). Avec ça, le user contrôle, et Rubis automatise tout le reste.
|
||||
|
||||
### 5.2 Architecture des `CheckinTask`
|
||||
|
||||
- Un `CheckinTask` est créé à la création de l'invoice (sauf si `pending` future) — programmé pour `dueDate`.
|
||||
- Au moment où le job tourne (queue `checkins`), il envoie l'email à l'user, marque la task `sent`, mais **ne change PAS le statut de l'invoice côté prod** (TODO V1.5 — bascule en `awaiting_user_confirmation` quand l'email est envoyé).
|
||||
- L'user a 24h (TTL) pour cliquer un des 2 liens email. Au-delà, la task expire (status `expired`) — elle ne refire pas, mais l'user peut toujours répondre via la modale in-app ou la fiche.
|
||||
|
||||
### 5.3 Architecture des `RelanceTask`
|
||||
|
||||
- Créées **uniquement** quand l'user répond "Non" au check-in (ou clique "Relancer maintenant"). Pas avant.
|
||||
- Une `RelanceTask` correspond à un `PlanStep` × une `Invoice`. Status :
|
||||
- `scheduled` : en attente de fire
|
||||
- `sent` : email envoyé OK
|
||||
- `cancelled` : annulée (facture mark paid, statut litigation, etc.)
|
||||
- `failed` : échec d'envoi définitif (5 retry exponentiels)
|
||||
- Le job BullMQ `relance-{taskId}` fire à `task.sendAt`. Idempotent : si la task n'est plus `scheduled`, no-op.
|
||||
|
||||
### 5.4 Les 4 surfaces qui peuvent répondre au check-in
|
||||
|
||||
Toutes appellent in fine la même logique métier (mêmes mutations DB, mêmes effets) :
|
||||
|
||||
| Surface | Endpoint | Auth |
|
||||
|---|---|---|
|
||||
| Email lien `paid` | `GET /api/v1/checkin/:token/paid` | Token URL |
|
||||
| Email lien `pending` | `GET /api/v1/checkin/:token/pending` | Token URL |
|
||||
| Modale in-app — Oui | `POST /api/v1/checkin/inapp/:invoiceId/paid` | Bearer auth |
|
||||
| Modale in-app — Non | `POST /api/v1/checkin/inapp/:invoiceId/pending` | Bearer auth |
|
||||
| Fiche facture — Marquer encaissée | `POST /api/v1/invoices/:id/mark-paid` | Bearer auth |
|
||||
| Fiche facture — Relancer maintenant | `POST /api/v1/checkin/inapp/:invoiceId/pending` | Bearer auth |
|
||||
| Démo — Oui | `POST /api/v1/invoices/:id/mark-paid` | Bearer auth |
|
||||
|
||||
---
|
||||
|
||||
## 6. Plans de relance
|
||||
|
||||
### 6.1 Structure
|
||||
|
||||
```
|
||||
Plan
|
||||
├── name: "Standard B2B"
|
||||
├── slug: "standard-b2b"
|
||||
├── description: "Plan équilibré pour la majorité des factures"
|
||||
└── steps[]
|
||||
├── PlanStep { offsetDays: 3, tone: "amical", subject, body, requiresManualValidation: false }
|
||||
├── PlanStep { offsetDays: 10, tone: "ferme", subject, body, requiresManualValidation: false }
|
||||
└── PlanStep { offsetDays: 25, tone: "stricte", subject, body, requiresManualValidation: true }
|
||||
↑
|
||||
mise en demeure → bouton manuel,
|
||||
jamais d'envoi auto
|
||||
```
|
||||
|
||||
### 6.2 Plans pré-fournis
|
||||
|
||||
| Plan | Cadence | Cible |
|
||||
|---|---|---|
|
||||
| **Standard B2B** | J+3, J+10, J+25 (mise en demeure brouillon) | Majorité des factures |
|
||||
| **Rapide** | J+1, J+5, J+10 — ton ferme | Cash flow tendu |
|
||||
| **Patient** | J+15, J+30, J+60 — ton doux | Clients VIP, partenaires |
|
||||
| **Ferme** | J+0, J+5, J+10, J+15 — ton ferme dès le départ | Grosses factures, clients à risque |
|
||||
|
||||
### 6.3 Création custom
|
||||
|
||||
Wizard 4 étapes (`/plans/nouveau`) :
|
||||
1. **Identité** : nom, description, slug auto
|
||||
2. **Cadence** : éditeur calendrier visuel pour placer les J+X
|
||||
3. **Messages** : pour chaque étape, choisir ton + écrire/IA-générer le contenu (avec variables `{{client.name}}`, `{{numero}}`, etc.)
|
||||
4. **Récap** : preview avant création
|
||||
|
||||
### 6.4 Variables dans les templates
|
||||
|
||||
Disponibles dans `subject` et `body` des steps :
|
||||
- `{{client.name}}`, `{{client.email}}`, `{{client.contactFirstName}}`, `{{client.contactLastName}}`
|
||||
- `{{user.fullName}}`, `{{user.companyName}}`
|
||||
- `{{numero}}`, `{{amount}}`, `{{dueDate}}`, `{{issueDate}}`
|
||||
- `{{daysLate}}` (jours de retard arrondis, démo-aware via `clock.now()`)
|
||||
- `{{signature}}` (de l'user, posée en /parametres)
|
||||
|
||||
Render via une fonction de substitution simple (pas Mustache section, pas de logique conditionnelle).
|
||||
|
||||
---
|
||||
|
||||
## 7. Mode démo
|
||||
|
||||
### 7.1 Flag d'activation
|
||||
|
||||
`Organization.demoMode = true` (toggle dans `/parametres`). Tant que ce flag est false, **rien** dans l'app ne dévie de la prod.
|
||||
|
||||
### 7.2 Effets côté prod
|
||||
|
||||
**Une seule** branche dans le code prod, dans `mail_dispatcher.ts` → `captureEmailIfDemo()` :
|
||||
- Si `org.demoMode = true` → l'email n'est PAS envoyé via SMTP/Resend, capturé en BDD (`demo_captured_emails`)
|
||||
- Sinon → envoi normal
|
||||
|
||||
Tout le reste (idempotence, status update, rubis bump, BullMQ, scheduling) tourne identique à la prod.
|
||||
|
||||
### 7.3 Horloge virtuelle
|
||||
|
||||
`Organization.virtualNow` (timestamp). En mode démo, **toutes** les fonctions qui lisent l'heure passent par `clock.now(orgId)` qui :
|
||||
- En prod (demoMode=false) → `DateTime.utc()`
|
||||
- En démo → `org.virtualNow` (cache 250ms pour éviter le spam DB)
|
||||
|
||||
Conséquence : on peut faire avancer l'horloge à 1x/2x/5x via le hook `useDemoTick` côté SPA (rAF loop + sync backend toutes les 250ms). À chaque event qui fire (relance dûe, check-in dû), l'horloge se met en pause et le slide-over s'ouvre.
|
||||
|
||||
### 7.4 Sortir du mode démo
|
||||
|
||||
`/demo/end` — l'org repasse en demoMode=false. La boîte capturée reste consultable (on ne wipe pas).
|
||||
|
||||
---
|
||||
|
||||
## 8. KPIs et calculs
|
||||
|
||||
### 8.1 Compteur rubis
|
||||
|
||||
- Crédité +1 à chaque `invoice_paid` (mark paid). Sur l'invoice (`rubisEarned`) ET sur l'org (`rubisCount`).
|
||||
- "Rubis ce mois" = somme des `rubisEarned` des factures payées avec `paidAt >= startOfMonth(now)`.
|
||||
- Conversion **1 rubis = 10 minutes libérées** affichée partout (ex: 18 rubis ≈ 3h libérées).
|
||||
|
||||
### 8.2 Encaissé
|
||||
|
||||
Somme des `amountTtcCents` des factures `status = 'paid'` :
|
||||
- Sur le mois (KPI dashboard)
|
||||
- Sur N derniers mois (chart Insights, bucket mensuel ou hebdo selon range)
|
||||
- Delta vs mois précédent (KPI dashboard)
|
||||
|
||||
### 8.3 DSO (Days Sales Outstanding)
|
||||
|
||||
Pour chaque facture payée : `daysToPayment = paidAt - issueDate`.
|
||||
DSO du mois = moyenne arithmétique sur les factures `paid_at >= startOfMonth(now)`.
|
||||
|
||||
### 8.4 Pipeline (chart Insights)
|
||||
|
||||
Donut + légende avec count + montant TTC par statut :
|
||||
- pending, awaiting_user_confirmation, in_relance, litigation, paid
|
||||
- `cancelled` exclus (bruit).
|
||||
|
||||
---
|
||||
|
||||
## 9. Edge cases & règles à connaître
|
||||
|
||||
### 9.1 Plan changé sur facture en cours
|
||||
|
||||
Si l'user change le plan d'une facture qui est `in_relance` :
|
||||
- Les `RelanceTask` `scheduled` du précédent plan sont `cancelled` (jobs BullMQ removed).
|
||||
- Les nouvelles tasks sont créées selon le nouveau plan.
|
||||
- Les tasks `sent` restent intactes (historique).
|
||||
|
||||
### 9.2 Facture marquée payée pendant qu'une relance est en queue
|
||||
|
||||
Le worker BullMQ check `invoice.status === 'paid'` au moment de fire :
|
||||
- Si oui → cancel la task, no-op.
|
||||
- Sinon → envoi normal.
|
||||
|
||||
C'est ce qui évite l'envoi d'une relance après mark paid.
|
||||
|
||||
### 9.3 Idempotence du check-in
|
||||
|
||||
Si l'user clique 2× le lien `/checkin/:token/paid` :
|
||||
- 1er clic : `task.status = 'answered'`, redirect SPA avec succès.
|
||||
- 2e clic : task déjà answered → redirect `?checkin=already_answered` (toast "Cette confirmation avait déjà été traitée").
|
||||
|
||||
### 9.4 Re-pop modale au refocus
|
||||
|
||||
L'user qui dismiss la modale puis bascule sur Slack et revient → modale re-pop si pending non vide (cf. §4.1). Pas de spam si l'user reste sur l'onglet.
|
||||
|
||||
### 9.5 Mode démo et signature
|
||||
|
||||
L'`User.signature` (posée en /parametres) est interpolée dans tous les templates `{{signature}}`. En démo, mêmes signatures que prod — la capture montre exactement ce qui partirait en réel.
|
||||
|
||||
---
|
||||
|
||||
## 10. Métriques produit à instrumenter
|
||||
|
||||
(reprise + précisions vs `produit.md` §9)
|
||||
|
||||
- **Taux de réponse au check-in** : % des CheckinTask `answered` vs total `sent`. Cible : >70%. Si bas → l'email check-in n'est pas lu, à itérer (subject, timing).
|
||||
- **Latence de réponse** : médiane du `answeredAt - sentAt`. Cible : <24h.
|
||||
- **Ratio Oui/Non au check-in** : si trop de "Oui" → on relance des factures déjà payées hors plateforme (signal pour pousser banking V2). Si trop de "Non" → cadence cohérente.
|
||||
- **Conversion `pending` → `paid` sans relance** : combien de factures sont marquées payées avant la 1re relance. Indicateur de bonne hygiène utilisateur.
|
||||
- **DSO moyen pré/post Rubis** : à mesurer via export historique vs progression mensuelle.
|
||||
- **% factures qui passent en `litigation`** : doit rester <2%. Au-delà, c'est que les plans sont trop agressifs.
|
||||
|
||||
---
|
||||
|
||||
## 11. Ce que Rubis ne fait PAS (rappel)
|
||||
|
||||
| Hors-scope | Pourquoi |
|
||||
|---|---|
|
||||
| Émettre des factures | On n'est pas un Henrri-bis. On relance ce qui sort d'ailleurs. |
|
||||
| Réconciliation banking auto | V2+. V1 = check-in email. |
|
||||
| Relancer par SMS | V2 (réservé plan le plus cher). |
|
||||
| Multi-utilisateurs | V2 (plans payants seulement). |
|
||||
| CRM / pipeline commercial | On reste pure-player relance. |
|
||||
| Recouvrement contentieux | Hors-scope définitif. La mise en demeure est le seuil. Au-delà, c'est huissier. |
|
||||
|
||||
---
|
||||
|
||||
*Dernière maj : 2026-05-07. Maintenu par Arthur + Claude.*
|
||||
Loading…
x
Reference in New Issue
Block a user