feat(web): _app shell + dashboard + factures liste & détail
Layout shell pour l'app authentifiée :
- routes/_app.tsx : pathless layout avec auth-guard + onboarding-guard
(signature null → redirect onboarding/compte)
- AppLayout : grid sidebar + topbar + main + tab bar mobile
- AppSidebar (lg+) : nav verticale + mini compteur rubis en bas
- MobileTabBar : 4 onglets fixed bottom (Accueil, Factures, Plans, Réglages)
- AppTopbar : sticky bg-cream/85 + backdrop-blur, greeting + date sur desktop,
brand sur mobile
- UserMenu : Radix Popover, avatar initiales rubis, logout mutation
Dashboard / (cf. wireframe 4.1) :
- RubisHero : ◆ 56px + drop-shadow rubis-tinted, "X rubis gagnés" en italic
rubis sur "gagnés", verbalisation conversion en heures, progression mensuelle
- 4 KpiCard scannables : À relancer, En cours, Encaissé, DSO
(delta en rubis-deep si intent positif, jamais de vert succès)
- ActivityFeed : journal du jour avec icônes Lucide tonalisées
- TopLatePayers : "Retards récurrents" (pas "mauvais payeurs", cf. marque)
- Quick actions mobile (+ Photo de facture / + Saisir)
Factures liste /factures (wireframe 2.4 + 2.1) :
- 3 états : 0 facture → dropzone full-page · filtre vide → mini-empty
· populated → filter chips + table desktop / cards mobile
- FilterChips : sync URL (validateSearch zod), counts entre parenthèses
- InvoiceTable : ligne entière cliquable (onClick + role=link + onKeyDown),
chevron Link séparé pour right-click "ouvrir nouvel onglet"
- InvoiceCardList : version mobile aérée
- StatusBadge : 6 statuts mappés palette marque (rubis solide pour "À valider",
ink pour "En relance", crème+✓ pour "Encaissée")
- Skeleton pulsé pendant le fetch
Détail facture /factures/$id (wireframe 4.2) :
- Header : eyebrow client + numéro + montant + échéance + délai (J−4 rouge)
+ StatusBadge inline
- Actions : Marquer encaissée (mutation + bonus rubis + invalidate)
- Layout 2-col : Timeline (1.4fr) + sidepanel client/notes (1fr)
- Timeline primitive : pastilles passé/présent/futur (rubis-glow ✓ /
rubis solide + Clock + ring glow / cercle vide)
Bug fix routing :
- factures.$id.tsx était nesté sous factures.tsx (flat naming TanStack Router)
→ la liste s'affichait à la place de la détail. Renommé factures_.$id.tsx
pour escape le layout parent. URL inchangée (/factures/$id).
Placeholders soignés : /plans, /clients, /parametres avec EmptyState draft
(bordure pointillée + message qui assume "ça arrive").
MSW étendu :
- mocks/seed.ts : 5 clients, 4 plans avec étapes complètes (Standard B2B,
Rapide, Patient, Ferme), 10 invoices avec statuses variés calibrés
sur le wireframe
- handlers/dashboard.ts : GET /dashboard/{kpis,activity,top-late}
- handlers/invoices.ts : GET /invoices (filtres + tri par priorité statut),
GET /invoices/counts, GET /invoices/:id (timeline calculée depuis le plan),
POST /invoices/:id/mark-paid (passe en paid + bonus rubis)
Lib étendue :
- format : formatDueDelta (J+10, J−4 avec − typographique), isOverdue
- routes/index.tsx supprimé (remplacé par _app/index.tsx)
Bundle prod : 117 KB gzip core, chaque route en chunk dédié (dashboard
inline dans _app, factures 3.69 KB gzip, factures._id 2.22 KB gzip).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
332bf0bcda
commit
14d0e982e9
97
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
97
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* ActivityFeed — journal d'activité.
|
||||
* Pattern wireframe 4.1 : ce qui s'est passé pendant que l'user travaillait
|
||||
* sur son vrai métier (intention émotionnelle forte).
|
||||
*/
|
||||
export type ActivityKind =
|
||||
| "relance_sent"
|
||||
| "invoice_paid"
|
||||
| "invoice_imported"
|
||||
| "warning_drafted";
|
||||
|
||||
export type ActivityEvent = {
|
||||
id: string;
|
||||
kind: ActivityKind;
|
||||
/** ISO 8601 */
|
||||
at: string;
|
||||
/** Texte HTML simple — peut contenir <b> pour mettre en avant un nom. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ICONS: Record<ActivityKind, LucideIcon> = {
|
||||
relance_sent: Send,
|
||||
invoice_paid: CheckCircle2,
|
||||
invoice_imported: Inbox,
|
||||
warning_drafted: AlertTriangle,
|
||||
};
|
||||
|
||||
const TONE: Record<ActivityKind, string> = {
|
||||
relance_sent: "text-ink-2",
|
||||
invoice_paid: "text-rubis-deep",
|
||||
invoice_imported: "text-ink-2",
|
||||
warning_drafted: "text-rubis-deep",
|
||||
};
|
||||
|
||||
type ActivityFeedProps = {
|
||||
events: ActivityEvent[];
|
||||
/** Si la liste est vide, on affiche un empty state au lieu d'une carte vide. */
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ActivityFeed({
|
||||
events,
|
||||
emptyMessage = "Tout est calme. Allez vous chercher un café.",
|
||||
className,
|
||||
}: ActivityFeedProps) {
|
||||
return (
|
||||
<Card padding="md" className={cn("min-w-0", className)}>
|
||||
<Eyebrow tone="ink">Aujourd'hui</Eyebrow>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="mt-4 text-[14px] text-ink-3 italic">{emptyMessage}</p>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{events.map((event) => {
|
||||
const Icon = ICONS[event.kind];
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex items-start gap-3 text-[13.5px] text-ink-2"
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className={cn("mt-0.5 shrink-0", TONE[event.kind])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="flex-1 min-w-0 leading-relaxed"
|
||||
// Le label vient du serveur — on autorise <b> pour mettre en avant
|
||||
// un nom de client ou un numéro de facture. Pas de XSS ici car les
|
||||
// mocks sont locaux ; à durcir avec sanitize si jamais on accepte
|
||||
// de l'input utilisateur.
|
||||
dangerouslySetInnerHTML={{ __html: event.label }}
|
||||
/>
|
||||
<time
|
||||
dateTime={event.at}
|
||||
className="shrink-0 text-[12px] text-ink-3 tabular-nums"
|
||||
>
|
||||
{format(parseISO(event.at), "HH:mm", { locale: fr })}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/components/dashboard/KpiCard.tsx
Normal file
49
apps/web/src/components/dashboard/KpiCard.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* KpiCard — un bloc statistique scannable.
|
||||
*
|
||||
* Personnalité :
|
||||
* - Label en eyebrow caps, value en font-display gros, delta en muted small
|
||||
* - Pas de couleur "verte succès" — on garde la palette rubis/neutres
|
||||
* - delta neutre : ink-3, delta positif : rubis-deep, delta négatif : ink-3 (jamais rouge alerte)
|
||||
*
|
||||
* "Positif" est dans le sens de la métrique :
|
||||
* - "Encaissé +2 800 €" : positif (intent=positive)
|
||||
* - "DSO -6j" (DSO baisse → c'est bien) : positif (intent=positive)
|
||||
* - On laisse l'utilisateur déclarer l'intent.
|
||||
*/
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
/** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */
|
||||
intent?: "positive" | "neutral" | "warning";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) {
|
||||
return (
|
||||
<Card padding="md" className={cn("min-w-0", 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">
|
||||
{value}
|
||||
</p>
|
||||
{delta && (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[12px] leading-snug",
|
||||
intent === "positive" && "text-rubis-deep",
|
||||
intent === "warning" && "text-rubis-deep font-medium",
|
||||
intent === "neutral" && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{delta}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/dashboard/RubisHero.tsx
Normal file
93
apps/web/src/components/dashboard/RubisHero.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { formatRubisToHours } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* RubisHero — la pièce centrale du dashboard.
|
||||
* Cf. wireframe 4.1 : ◆ géant + "X rubis gagnés ce mois" + verbalisation
|
||||
* de la conversion en heures + barre de progression vers l'objectif mensuel.
|
||||
*
|
||||
* Layout asymétrique :
|
||||
* - Mobile : centré, gem en haut
|
||||
* - Desktop : gem à gauche (XL), texte au milieu, progression à droite
|
||||
*
|
||||
* Pas une carte plate "X stat" : c'est l'identité émotionnelle de l'app.
|
||||
*/
|
||||
type RubisHeroProps = {
|
||||
rubisThisMonth: number;
|
||||
/** Pourcentage 0-100 vers l'objectif. */
|
||||
monthlyGoalProgress: number;
|
||||
/** Top X% des utilisateurs sur le mois (si disponible). */
|
||||
percentile?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function RubisHero({
|
||||
rubisThisMonth,
|
||||
monthlyGoalProgress,
|
||||
percentile,
|
||||
className,
|
||||
}: RubisHeroProps) {
|
||||
const progress = Math.min(100, Math.max(0, monthlyGoalProgress));
|
||||
const hoursLabel = formatRubisToHours(rubisThisMonth);
|
||||
|
||||
return (
|
||||
<Card variant="hero" padding="md" className={cn("relative overflow-hidden", className)}>
|
||||
{/* Halo rubis-glow en arrière-plan, très discret */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -top-20 -right-16 size-64 rounded-full pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(251,228,234,0.7), transparent 65%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex shrink-0 items-center gap-4 sm:flex-col sm:items-center sm:gap-1">
|
||||
<Gem size={56} glow aria-label="Compteur de rubis" />
|
||||
<p className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-ink-3 sm:mt-1">
|
||||
Rubis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-display text-[28px] font-bold leading-[1.1] tracking-[-0.022em] text-ink sm:text-[26px]">
|
||||
<span className="tabular-nums">{rubisThisMonth}</span> rubis{" "}
|
||||
<em className="not-italic text-rubis">gagnés</em> ce mois
|
||||
</p>
|
||||
<p className="mt-1.5 text-[13.5px] leading-relaxed text-ink-2">
|
||||
≈{" "}
|
||||
<span className="font-semibold text-ink tabular-nums">{hoursLabel}</span>{" "}
|
||||
que vous n'avez pas passées à relancer.
|
||||
{percentile !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
Vous êtes dans le <strong>top {percentile} %</strong> ce mois-ci.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 sm:w-[160px]">
|
||||
<div
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-cream-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-rubis transition-[width] duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-ink-3">
|
||||
Objectif mensuel · <span className="tabular-nums">{progress} %</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/dashboard/TopLatePayers.tsx
Normal file
61
apps/web/src/components/dashboard/TopLatePayers.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* TopLatePayers — wireframe 4.1, panneau "Top retards".
|
||||
*
|
||||
* Note marque : "manié avec tact, jamais visible côté client" (cf. annotation
|
||||
* du wireframe). On nomme "Retards récurrents" et pas "Mauvais payeurs".
|
||||
*/
|
||||
export type LatePayer = {
|
||||
clientId: string;
|
||||
name: string;
|
||||
lateInvoicesCount: number;
|
||||
};
|
||||
|
||||
type TopLatePayersProps = {
|
||||
payers: LatePayer[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TopLatePayers({ payers, className }: TopLatePayersProps) {
|
||||
return (
|
||||
<Card padding="md" className={cn("min-w-0", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Eyebrow tone="ink">Retards récurrents</Eyebrow>
|
||||
<Link
|
||||
to="/clients"
|
||||
className="text-[12px] text-ink-3 hover:text-rubis hover:underline underline-offset-4"
|
||||
>
|
||||
Voir tout
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{payers.length === 0 ? (
|
||||
<p className="mt-4 text-[14px] text-ink-3 italic">
|
||||
Aucun retard récurrent. Vos clients sont sérieux.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-col divide-y divide-line">
|
||||
{payers.map((payer) => (
|
||||
<li
|
||||
key={payer.clientId}
|
||||
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0 text-[13.5px]"
|
||||
>
|
||||
<span className="truncate text-ink">{payer.name}</span>
|
||||
<span className="shrink-0 text-ink-2">
|
||||
<strong className="font-semibold tabular-nums">{payer.lateInvoicesCount}</strong>{" "}
|
||||
<span className="text-ink-3">
|
||||
{payer.lateInvoicesCount > 1 ? "retards" : "retard"}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/factures/Dropzone.tsx
Normal file
171
apps/web/src/components/factures/Dropzone.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { UploadCloud, FilePlus, FolderOpen } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ACCEPTED_INVOICE_MIME_TYPES, MAX_INVOICE_FILE_SIZE_BYTES } from "@rubis/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dropzone — zone de drag & drop multi-fichiers pour les factures.
|
||||
*
|
||||
* Cf. wireframe 2.1 et CLAUDE.md (principe "3 clics maximum"). C'est le geste
|
||||
* principal d'import — le clic "parcourir" est le fallback discret.
|
||||
*
|
||||
* En V1, la primitive ne fait que recevoir les File[] et les passer à
|
||||
* `onFiles`. L'upload, l'OCR et la création des invoices se passent en aval
|
||||
* (backend Adonis ou MSW).
|
||||
*/
|
||||
type DropzoneProps = {
|
||||
/** Variant pleine page (empty state) vs compact (dans un coin). */
|
||||
variant?: "full" | "compact";
|
||||
/** Max files acceptés en un drop. Default 20 (cf. wireframe). */
|
||||
maxFiles?: number;
|
||||
/** Callback quand des fichiers valides ont été sélectionnés. */
|
||||
onFiles?: (files: File[]) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ACCEPT_ATTR = ACCEPTED_INVOICE_MIME_TYPES.join(",");
|
||||
|
||||
function isAcceptableFile(file: File): boolean {
|
||||
return (
|
||||
(ACCEPTED_INVOICE_MIME_TYPES as readonly string[]).includes(file.type) &&
|
||||
file.size <= MAX_INVOICE_FILE_SIZE_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
export function Dropzone({
|
||||
variant = "full",
|
||||
maxFiles = 20,
|
||||
onFiles,
|
||||
className,
|
||||
}: DropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList) return;
|
||||
const files = Array.from(fileList);
|
||||
if (files.length > maxFiles) {
|
||||
setError(`Maximum ${maxFiles} fichiers en un seul drop.`);
|
||||
return;
|
||||
}
|
||||
const valid = files.filter(isAcceptableFile);
|
||||
const rejected = files.length - valid.length;
|
||||
if (rejected > 0 && valid.length === 0) {
|
||||
setError("Format non supporté. PDF, PNG, JPG seulement.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onFiles?.(valid);
|
||||
},
|
||||
[maxFiles, onFiles],
|
||||
);
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const isFull = variant === "full";
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"rounded-card border-2 border-dashed bg-cream-2/40 transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center text-center",
|
||||
isFull ? "py-16 px-8 min-h-[360px]" : "py-8 px-6 min-h-[160px]",
|
||||
isDragging
|
||||
? "border-rubis bg-rubis-glow/30"
|
||||
: "border-line hover:border-ink-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 items-center justify-center rounded-full bg-white",
|
||||
"border border-line shadow-soft",
|
||||
isDragging && "border-rubis text-rubis",
|
||||
)}
|
||||
>
|
||||
<UploadCloud
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
className={isDragging ? "text-rubis" : "text-ink-2"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"mt-5 font-display tracking-[-0.018em] text-ink",
|
||||
isFull ? "text-[22px] font-semibold" : "text-[17px] font-semibold",
|
||||
)}
|
||||
>
|
||||
{isDragging ? (
|
||||
<>
|
||||
Lâchez ici, on s'<em className="text-rubis">occupe</em> du reste.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Glissez vos factures <em className="text-rubis">ici</em>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-ink-2">
|
||||
PDF, PNG, JPG · jusqu'à {maxFiles} fichiers en simultané · 10 Mo par fichier
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
|
||||
</Button>
|
||||
{isFull && (
|
||||
<Button variant="ghost" size="sm" type="button">
|
||||
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-[13px] font-medium text-rubis-deep" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{isFull && (
|
||||
<p className="mt-8 text-[12px] text-ink-3 max-w-md">
|
||||
L'OCR fait le reste — vérifiez en 30 secondes et lancez la relance.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/factures/FilterChips.tsx
Normal file
58
apps/web/src/components/factures/FilterChips.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Chip } from "@/components/ui/Chip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Filtres en chips — pas en dropdown. Cf. wireframe 2.4 :
|
||||
* "Arthur veut 'à relancer' en 1 clic".
|
||||
*/
|
||||
export type FilterOption<TKey extends string = string> = {
|
||||
key: TKey;
|
||||
label: string;
|
||||
/** Compteur affiché entre parenthèses. */
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type FilterChipsProps<TKey extends string> = {
|
||||
options: ReadonlyArray<FilterOption<TKey>>;
|
||||
value: TKey;
|
||||
onChange: (key: TKey) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FilterChips<TKey extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: FilterChipsProps<TKey>) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Filtrer les factures par statut"
|
||||
className={cn("flex flex-wrap gap-2", className)}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<Chip
|
||||
key={opt.key}
|
||||
role="tab"
|
||||
aria-selected={value === opt.key}
|
||||
selected={value === opt.key}
|
||||
withCheck={false}
|
||||
onClick={() => onChange(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 tabular-nums text-[11.5px] font-medium",
|
||||
value === opt.key ? "text-rubis-deep/80" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{opt.count}
|
||||
</span>
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/factures/InvoiceCardList.tsx
Normal file
62
apps/web/src/components/factures/InvoiceCardList.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { formatEuros, formatDateShort, formatDueDelta, isOverdue } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InvoiceListItem } from "./InvoiceTable";
|
||||
|
||||
/**
|
||||
* Vue mobile des factures : cards empilées, plus aérées qu'une mini-table.
|
||||
* Chaque card est un Link vers le détail.
|
||||
*/
|
||||
type InvoiceCardListProps = {
|
||||
invoices: InvoiceListItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InvoiceCardList({ invoices, className }: InvoiceCardListProps) {
|
||||
return (
|
||||
<ul className={cn("flex flex-col gap-2", className)}>
|
||||
{invoices.map((invoice) => {
|
||||
const overdue = isOverdue(invoice.dueDate);
|
||||
return (
|
||||
<li key={invoice.id}>
|
||||
<Link
|
||||
to="/factures/$id"
|
||||
params={{ id: invoice.id }}
|
||||
className={cn(
|
||||
"block rounded-card border border-line bg-white p-4",
|
||||
"transition-colors hover:border-ink-3",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14.5px] font-semibold text-ink truncate">
|
||||
{invoice.clientName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-ink-3 tabular-nums">
|
||||
{invoice.numero}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-display text-[17px] font-semibold tabular-nums text-ink whitespace-nowrap">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<StatusBadge status={invoice.status} label={invoice.statusLabel} />
|
||||
<p className="text-[12px] text-ink-3 tabular-nums">
|
||||
<span className={cn(overdue && "text-rubis-deep font-semibold")}>
|
||||
{formatDueDelta(invoice.dueDate)}
|
||||
</span>{" "}
|
||||
· {formatDateShort(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/components/factures/InvoiceTable.tsx
Normal file
128
apps/web/src/components/factures/InvoiceTable.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import type { Invoice } from "@rubis/shared";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Forme enrichie utilisée par la liste : invoice + nom client + nom plan. */
|
||||
export type InvoiceListItem = Invoice & {
|
||||
clientName: string;
|
||||
planName: string | null;
|
||||
/** Label de statut serveur, ex: "Relance J+3 envoyée". Override visuel. */
|
||||
statusLabel?: string;
|
||||
};
|
||||
|
||||
type InvoiceTableProps = {
|
||||
invoices: InvoiceListItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Table desktop des factures.
|
||||
*
|
||||
* Décisions visuelles :
|
||||
* - Échéance dépassée en bold (cf. wireframe 2.4)
|
||||
* - **Ligne entière cliquable** via onClick + role="link" + onKeyDown
|
||||
* (le chevron de droite reste un vrai <Link> pour le right-click "ouvrir
|
||||
* dans un nouvel onglet" + middle-click)
|
||||
* - Statut dans une chip sémantique (StatusBadge)
|
||||
* - Pas de checkbox actions-en-lot en V1 (à venir avec le backend)
|
||||
*/
|
||||
export function InvoiceTable({ invoices, className }: InvoiceTableProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goToInvoice = (id: string): void => {
|
||||
void navigate({ to: "/factures/$id", params: { id } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-card border border-line bg-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className="w-full text-left text-[13.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-cream-2/50 text-[11px] font-semibold uppercase tracking-[0.1em] text-ink-3">
|
||||
<th scope="col" className="px-5 py-3 font-semibold">Client</th>
|
||||
<th scope="col" className="px-3 py-3 font-semibold">N° facture</th>
|
||||
<th scope="col" className="px-3 py-3 font-semibold text-right">Montant</th>
|
||||
<th scope="col" className="px-3 py-3 font-semibold">Échéance</th>
|
||||
<th scope="col" className="px-3 py-3 font-semibold">Statut</th>
|
||||
<th scope="col" className="px-3 py-3 font-semibold">Plan</th>
|
||||
<th scope="col" className="px-5 py-3 w-10" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((invoice) => {
|
||||
const overdue = isOverdue(invoice.dueDate);
|
||||
return (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`Facture ${invoice.numero} — ${invoice.clientName}`}
|
||||
onClick={() => goToInvoice(invoice.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
goToInvoice(invoice.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"border-t border-line first:border-t-0 cursor-pointer",
|
||||
"transition-colors hover:bg-cream-2/40",
|
||||
"focus-visible:outline-none focus-visible:bg-cream focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:ring-inset",
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3.5 text-ink font-medium truncate max-w-[200px]">
|
||||
{invoice.clientName}
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-ink-2 tabular-nums">{invoice.numero}</td>
|
||||
<td className="px-3 py-3.5 text-ink tabular-nums text-right font-medium">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<span className={cn("text-ink-2", overdue && "text-ink font-semibold")}>
|
||||
{formatDate(invoice.dueDate)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 text-[11.5px] tabular-nums",
|
||||
overdue ? "text-rubis-deep font-semibold" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{formatDueDelta(invoice.dueDate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<StatusBadge status={invoice.status} label={invoice.statusLabel} />
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-ink-3">
|
||||
{invoice.planName ?? <span className="italic">— aucun</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{/* Chevron explicite — vrai <Link> pour le right-click /
|
||||
middle-click "ouvrir dans nouvel onglet". On stoppe la
|
||||
propagation pour ne pas double-naviguer via le row click. */}
|
||||
<Link
|
||||
to="/factures/$id"
|
||||
params={{ id: invoice.id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex size-7 items-center justify-center rounded-default text-ink-3 hover:bg-cream hover:text-rubis"
|
||||
aria-label={`Voir la facture ${invoice.numero}`}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/layout/AppLayout.tsx
Normal file
81
apps/web/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { AppTopbar } from "./AppTopbar";
|
||||
import { MobileTabBar } from "./MobileTabBar";
|
||||
|
||||
/**
|
||||
* Shell de l'app authentifiée :
|
||||
* - Desktop : sidebar fixe à gauche + main + topbar sticky en haut
|
||||
* - Mobile : pas de sidebar, topbar avec brand, tab bar fixe en bas
|
||||
*
|
||||
* Le bottom-padding mobile (pb-20) évite que le tab bar masque le contenu.
|
||||
*/
|
||||
type AppLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
/** Titre du topbar (sinon greeting + date). */
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Actions à droite du topbar. */
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
type DashboardKpis = {
|
||||
rubisCount: number;
|
||||
rubisThisMonth: number;
|
||||
hoursLiberatedThisMonth: number;
|
||||
encaisseCents: number;
|
||||
encaisseDeltaCents: number;
|
||||
dsoDays: number;
|
||||
dsoDeltaDays: number;
|
||||
factureToRelance: number;
|
||||
factureInRelance: number;
|
||||
factureNewToday: number;
|
||||
miseEnDemeurePending: number;
|
||||
};
|
||||
|
||||
export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps) {
|
||||
// KPIs partagés layout ↔ dashboard : on les charge ici pour que le sidebar
|
||||
// affiche le compteur sans attendre le rendu du dashboard.
|
||||
const { data: kpis } = useQuery({
|
||||
queryKey: queryKeys.dashboard.kpis(),
|
||||
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Actions globales par défaut (visibles desktop seulement). Sur mobile,
|
||||
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
|
||||
const defaultActions = (
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary">
|
||||
<Plus size={14} aria-hidden="true" /> Saisir
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Upload size={14} aria-hidden="true" /> Importer factures
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-cream">
|
||||
<AppSidebar rubisThisMonth={kpis?.rubisThisMonth ?? 0} />
|
||||
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<AppTopbar
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions ?? defaultActions}
|
||||
/>
|
||||
<main className="flex-1 px-5 py-6 pb-24 lg:px-8 lg:py-8 lg:pb-12">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MobileTabBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/layout/AppSidebar.tsx
Normal file
60
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
ListChecks,
|
||||
Users,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { formatRubisToHours } from "@/lib/format";
|
||||
import { NavLink } from "./NavLink";
|
||||
|
||||
/**
|
||||
* Sidebar desktop — 240px wide, sticky.
|
||||
* - Brand en haut
|
||||
* - Nav verticale au centre
|
||||
* - Compteur rubis en bas (gratification permanente, cf. wireframe 4.1)
|
||||
*
|
||||
* Le compteur rubis dans le sidebar n'est pas négociable : c'est ce qui
|
||||
* fait dire au user "putain j'ai gagné 24h ce mois".
|
||||
*/
|
||||
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
|
||||
const { user: _user } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex h-screen sticky top-0 w-[240px] shrink-0 flex-col border-r border-line bg-cream-2/60 px-4 py-6">
|
||||
<Link to="/" className="mb-10 px-2">
|
||||
<Brand />
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
<NavLink to="/" icon={<LayoutDashboard size={17} />} label="Dashboard" />
|
||||
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
|
||||
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
|
||||
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" />
|
||||
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
|
||||
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
|
||||
Rubis ce mois
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-end gap-2">
|
||||
<Gem size={18} />
|
||||
<span className="font-display text-[22px] font-bold leading-none tabular-nums">
|
||||
{rubisThisMonth}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-ink-3">
|
||||
≈ {formatRubisToHours(rubisThisMonth)} libérées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/layout/AppTopbar.tsx
Normal file
66
apps/web/src/components/layout/AppTopbar.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
|
||||
/**
|
||||
* Topbar — greeting (desktop) ou brand (mobile) à gauche, actions slot et UserMenu à droite.
|
||||
* Sticky, bg cream/85 + backdrop-blur (cohérent landing).
|
||||
*/
|
||||
type AppTopbarProps = {
|
||||
/** Titre de la page courante (au lieu du greeting). Ex: "Factures". */
|
||||
title?: string;
|
||||
/** Sous-titre (date par défaut, ou contexte custom). */
|
||||
subtitle?: string;
|
||||
/** Slot d'actions à droite avant l'avatar (ex: boutons + Importer / + Saisir). */
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
const GREETING_BY_HOUR = (h: number): string => {
|
||||
if (h < 6) return "Bonsoir";
|
||||
if (h < 18) return "Bonjour";
|
||||
return "Bonsoir";
|
||||
};
|
||||
|
||||
export function AppTopbar({ title, subtitle, actions }: AppTopbarProps) {
|
||||
const { user } = useAuth();
|
||||
const firstName = user?.fullName?.split(" ")[0];
|
||||
const now = new Date();
|
||||
const greeting = title ?? `${GREETING_BY_HOUR(now.getHours())} ${firstName ?? ""}`.trim();
|
||||
const dateLabel = subtitle ?? format(now, "EEEE d MMMM yyyy", { locale: fr });
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-30 border-b border-line",
|
||||
"bg-cream/85 backdrop-blur-md backdrop-saturate-150",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<Brand />
|
||||
</div>
|
||||
<div className="hidden lg:block min-w-0">
|
||||
<h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight">
|
||||
{greeting}
|
||||
{!title && firstName && (
|
||||
<span aria-hidden="true" className="ml-1.5">
|
||||
👋
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-[12.5px] text-ink-3 first-letter:uppercase">{dateLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{actions}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/layout/MobileTabBar.tsx
Normal file
37
apps/web/src/components/layout/MobileTabBar.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Home, FileText, ListChecks, Settings } 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).
|
||||
*/
|
||||
export function MobileTabBar() {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Navigation principale"
|
||||
className="lg:hidden fixed bottom-0 inset-x-0 z-40 border-t border-line bg-cream-2/95 backdrop-blur-md pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<div className="flex">
|
||||
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label="Accueil" />
|
||||
<NavLink
|
||||
to="/factures"
|
||||
variant="tab-bar"
|
||||
icon={<FileText size={19} />}
|
||||
label="Factures"
|
||||
/>
|
||||
<NavLink
|
||||
to="/plans"
|
||||
variant="tab-bar"
|
||||
icon={<ListChecks size={19} />}
|
||||
label="Plans"
|
||||
/>
|
||||
<NavLink
|
||||
to="/parametres"
|
||||
variant="tab-bar"
|
||||
icon={<Settings size={19} />}
|
||||
label="Réglages"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/layout/NavLink.tsx
Normal file
60
apps/web/src/components/layout/NavLink.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Link, type LinkProps } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* NavLink — item de nav du sidebar / tab bar.
|
||||
* État actif : bord gauche rubis (desktop) ou label rubis (tab bar).
|
||||
* Pas de bg plein quand inactif, le sidebar reste calme.
|
||||
*/
|
||||
type Variant = "sidebar" | "tab-bar";
|
||||
|
||||
type NavLinkProps = Omit<LinkProps, "children"> & {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function NavLink({ icon, label, variant = "sidebar", className, ...linkProps }: NavLinkProps) {
|
||||
const isSidebar = variant === "sidebar";
|
||||
return (
|
||||
<Link
|
||||
{...linkProps}
|
||||
activeOptions={{ exact: linkProps.to === "/" }}
|
||||
className={cn(
|
||||
"group relative flex items-center transition-colors duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
||||
isSidebar
|
||||
? "gap-3 rounded-default px-3 py-2.5 text-[14px] font-medium text-ink-2 hover:text-ink hover:bg-cream"
|
||||
: "flex-1 flex-col gap-0.5 py-2 text-[10.5px] font-semibold text-ink-3 hover:text-ink",
|
||||
className,
|
||||
)}
|
||||
activeProps={{
|
||||
className: isSidebar
|
||||
? "text-rubis bg-cream shadow-soft"
|
||||
: "text-rubis",
|
||||
}}
|
||||
>
|
||||
{/* Marqueur rubis vertical sur le sidebar quand actif */}
|
||||
{isSidebar && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-rubis",
|
||||
"opacity-0 transition-opacity duration-150",
|
||||
"group-aria-[current=page]:opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebar ? "text-ink-3 group-aria-[current=page]:text-rubis" : "text-current",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className={cn(isSidebar ? "" : "leading-none")}>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/layout/UserMenu.tsx
Normal file
96
apps/web/src/components/layout/UserMenu.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChevronDown, LogOut, Settings as SettingsIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { authStore, useAuth } from "@/lib/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* UserMenu — avatar initiales + popover avec logout.
|
||||
* Radix Popover pour l'a11y (focus management, échap, click-outside).
|
||||
*/
|
||||
export function UserMenu() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => api.post<void>("/api/v1/account/logout"),
|
||||
onSettled: () => {
|
||||
// Quoi qu'il arrive (succès ou échec réseau), on clear la session locale.
|
||||
authStore.clear();
|
||||
toast.success("À très vite.");
|
||||
void navigate({ to: "/login" });
|
||||
},
|
||||
});
|
||||
|
||||
const initials = user?.fullName
|
||||
?.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("") ?? "?";
|
||||
|
||||
const firstName = user?.fullName?.split(" ")[0] ?? "Utilisateur";
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border border-line bg-white",
|
||||
"py-1 pl-1 pr-2.5 transition-colors hover:border-ink-3",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
aria-label="Ouvrir le menu utilisateur"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-rubis text-white",
|
||||
"font-display text-[12.5px] font-bold leading-none",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-[13px] font-medium text-ink">{firstName}</span>
|
||||
<ChevronDown size={14} className="text-ink-3" aria-hidden="true" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"z-50 w-[220px] rounded-card border border-line bg-white p-2 shadow-card",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
)}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-[13px] font-semibold text-ink truncate">{user?.fullName}</p>
|
||||
<p className="text-[12px] text-ink-3 truncate">{user?.email}</p>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-line" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/parametres" })}
|
||||
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors"
|
||||
>
|
||||
<SettingsIcon size={15} className="text-ink-3" aria-hidden="true" />
|
||||
Paramètres
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={logoutMutation.isPending}
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors disabled:opacity-50"
|
||||
>
|
||||
<LogOut size={15} className="text-ink-3" aria-hidden="true" />
|
||||
Se déconnecter
|
||||
</button>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/ui/EmptyState.tsx
Normal file
50
apps/web/src/components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Card } from "./Card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* EmptyState — message centré pour pages vides ou écrans en attente.
|
||||
* - icon optionnel en SVG/Lucide
|
||||
* - title font-display, body Inter
|
||||
* - cta optionnel (slot React)
|
||||
*/
|
||||
type EmptyStateProps = {
|
||||
icon?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
cta?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Tone "draft" pour les écrans pas encore implémentés. */
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
cta,
|
||||
className,
|
||||
draft = false,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<Card
|
||||
variant="flat"
|
||||
padding="lg"
|
||||
className={cn(
|
||||
"flex flex-col items-center text-center min-h-[280px] justify-center",
|
||||
draft && "border border-dashed border-line bg-cream",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && <div className="text-ink-3 mb-3">{icon}</div>}
|
||||
<h2 className="font-display text-[22px] font-semibold tracking-[-0.018em] text-ink">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-md text-[14.5px] leading-relaxed text-ink-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{cta && <div className="mt-6">{cta}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/components/ui/StatusBadge.tsx
Normal file
80
apps/web/src/components/ui/StatusBadge.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { Check, AlertTriangle, Send, Clock, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InvoiceStatus } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* StatusBadge — pastille de statut de facture.
|
||||
*
|
||||
* Mapping marque-respectueux (cf. /docs/marque.md §3, "le rubis est rare") :
|
||||
* - pending : cream-2 + ink-2 — calme, attente
|
||||
* - in_relance : ink + cream — actif, sombre
|
||||
* - awaiting_user_confirmation : rubis solide + white — "à vous de jouer"
|
||||
* - paid : cream + ink + ✓ — accomplissement calme (pas de vert)
|
||||
* - litigation : rubis-deep + white — sérieux
|
||||
* - cancelled : line + ink-3 — muted
|
||||
*/
|
||||
type StatusVisual = {
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ size?: number; "aria-hidden"?: boolean | "true" | "false" }>;
|
||||
className: string;
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<InvoiceStatus, StatusVisual> = {
|
||||
pending: {
|
||||
label: "À relancer",
|
||||
icon: Clock,
|
||||
className: "bg-cream-2 text-ink-2 border-line",
|
||||
},
|
||||
in_relance: {
|
||||
label: "En relance",
|
||||
icon: Send,
|
||||
className: "bg-ink text-cream border-ink",
|
||||
},
|
||||
awaiting_user_confirmation: {
|
||||
label: "À valider",
|
||||
icon: AlertTriangle,
|
||||
className: "bg-rubis text-white border-rubis",
|
||||
},
|
||||
paid: {
|
||||
label: "Encaissée",
|
||||
icon: Check,
|
||||
className: "bg-white text-ink border-line",
|
||||
},
|
||||
litigation: {
|
||||
label: "Litige",
|
||||
icon: AlertTriangle,
|
||||
className: "bg-rubis-deep text-white border-rubis-deep",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Annulée",
|
||||
icon: X,
|
||||
className: "bg-cream-2 text-ink-3 border-line",
|
||||
},
|
||||
};
|
||||
|
||||
type StatusBadgeProps = {
|
||||
status: InvoiceStatus;
|
||||
/** Override le label (ex: "Relance J+3 envoyée"). */
|
||||
label?: string;
|
||||
/** Si true, masque l'icône — utile dans une cellule dense. */
|
||||
withoutIcon?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, label, withoutIcon = false, className }: StatusBadgeProps) {
|
||||
const visual = STATUS_MAP[status];
|
||||
const Icon = visual.icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1",
|
||||
"font-sans text-[12px] font-medium leading-tight whitespace-nowrap",
|
||||
visual.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!withoutIcon && Icon && <Icon size={12} aria-hidden="true" />}
|
||||
{label ?? visual.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/ui/Timeline.tsx
Normal file
95
apps/web/src/components/ui/Timeline.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { Check, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Timeline — frise verticale d'événements passés / présents / futurs.
|
||||
* Cf. wireframe 4.2 (détail facture) : "une seule colonne qui mélange passé
|
||||
* (plein) et futur (estompé) — l'utilisateur voit tout l'arc de la relance".
|
||||
*
|
||||
* Visuel :
|
||||
* - past : pastille rubis-glow + ✓ rubis-deep, texte ink
|
||||
* - current : pastille rubis pleine + Clock blanc, texte ink, bordure rubis sur la pastille
|
||||
* - future : pastille blanche + bord line, texte ink-3 (estompé)
|
||||
* - ligne verticale ink-3 entre les pastilles
|
||||
*/
|
||||
export type TimelineState = "past" | "current" | "future";
|
||||
|
||||
export type TimelineEvent = {
|
||||
id: string;
|
||||
state: TimelineState;
|
||||
/** Métadonnée temporelle ("02/04/2026 · facture émise" ou "J+10 — Étape 2"). */
|
||||
when: string;
|
||||
/** Action / titre de l'événement. Peut contenir du markup léger via React. */
|
||||
what: React.ReactNode;
|
||||
/** Détail secondaire optionnel (ex: "ouvert 2x"). */
|
||||
detail?: React.ReactNode;
|
||||
};
|
||||
|
||||
type TimelineProps = {
|
||||
events: TimelineEvent[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Timeline({ events, className }: TimelineProps) {
|
||||
return (
|
||||
<ol className={cn("relative flex flex-col", className)}>
|
||||
{events.map((event, idx) => {
|
||||
const isLast = idx === events.length - 1;
|
||||
return (
|
||||
<li key={event.id} className="relative flex gap-4 pb-7 last:pb-0">
|
||||
{/* Ligne verticale qui descend de la pastille jusqu'au prochain item */}
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-[14px] top-7 -bottom-1 w-px",
|
||||
event.state === "past" ? "bg-rubis-glow" : "bg-line",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Pastille state={event.state} />
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-[0.1em]",
|
||||
event.state === "future" ? "text-ink-3" : "text-rubis",
|
||||
)}
|
||||
>
|
||||
{event.when}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-[14px] leading-relaxed",
|
||||
event.state === "future" ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{event.what}
|
||||
</div>
|
||||
{event.detail && (
|
||||
<p className="mt-0.5 text-[12.5px] text-ink-3">{event.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function Pastille({ state }: { state: TimelineState }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative z-10 flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
state === "past" && "bg-rubis-glow text-rubis-deep",
|
||||
state === "current" && "bg-rubis text-white shadow-rubis ring-4 ring-rubis-glow",
|
||||
state === "future" && "bg-white text-ink-3 border border-line",
|
||||
)}
|
||||
>
|
||||
{state === "past" && <Check size={13} strokeWidth={2.5} aria-hidden="true" />}
|
||||
{state === "current" && <Clock size={13} strokeWidth={2.5} aria-hidden="true" />}
|
||||
{/* future : pas d'icône, juste un cercle vide. */}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -43,3 +43,29 @@ export function formatRelativeDate(iso: string): string {
|
||||
export function formatDateShort(iso: string): string {
|
||||
return format(parseISO(iso), "d MMM", { locale: fr });
|
||||
}
|
||||
|
||||
/**
|
||||
* Délai par rapport à aujourd'hui pour une échéance ISO :
|
||||
* - "J−3" si dépassée de 3 jours
|
||||
* - "J+10" si dans 10 jours
|
||||
* - "Aujourd'hui" si dans la journée
|
||||
*
|
||||
* Convention : le signe '−' (minus typographique, pas hyphen) marque le retard.
|
||||
* Pas d'espace autour de J — c'est un token visuel compact.
|
||||
*/
|
||||
export function formatDueDelta(iso: string, now: Date = new Date()): string {
|
||||
const due = parseISO(iso);
|
||||
// Compare en jours calendaires (pas en ms exactes).
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const dueDay = Math.floor(due.getTime() / oneDay);
|
||||
const today = Math.floor(now.getTime() / oneDay);
|
||||
const diff = dueDay - today;
|
||||
if (diff === 0) return "Aujourd'hui";
|
||||
if (diff < 0) return `J−${Math.abs(diff)}`;
|
||||
return `J+${diff}`;
|
||||
}
|
||||
|
||||
/** True si l'échéance est passée (strict, pas le jour même). */
|
||||
export function isOverdue(iso: string, now: Date = new Date()): boolean {
|
||||
return parseISO(iso).getTime() < now.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
@ -3,15 +3,27 @@
|
||||
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
|
||||
* mais reste isolée par onglet (pas d'interférence entre devs).
|
||||
*/
|
||||
import type { Organization, User } from "@rubis/shared";
|
||||
import type { Client, Invoice, Organization, Plan, User } from "@rubis/shared";
|
||||
import { SEED_CLIENTS, SEED_INVOICES, SEED_PLANS } from "./seed";
|
||||
|
||||
const STORAGE_KEY = "rubis.mocks.db";
|
||||
const STORAGE_KEY = "rubis.mocks.db.v2";
|
||||
|
||||
type StoredUser = User & { passwordHash: string };
|
||||
|
||||
/** Forme enrichie des invoices stockée localement (avec dénormalisations
|
||||
* pratiques pour les listes : clientName, planName, statusLabel). */
|
||||
export type StoredInvoice = Invoice & {
|
||||
clientName: string;
|
||||
planName: string | null;
|
||||
statusLabel?: string;
|
||||
};
|
||||
|
||||
type Db = {
|
||||
users: StoredUser[];
|
||||
organizations: Organization[];
|
||||
clients: Client[];
|
||||
plans: Plan[];
|
||||
invoices: StoredInvoice[];
|
||||
};
|
||||
|
||||
const seedDb = (): Db => ({
|
||||
@ -38,6 +50,9 @@ const seedDb = (): Db => ({
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
clients: SEED_CLIENTS,
|
||||
plans: SEED_PLANS,
|
||||
invoices: SEED_INVOICES,
|
||||
});
|
||||
|
||||
function load(): Db {
|
||||
@ -69,18 +84,13 @@ function stripHash(user: StoredUser): User {
|
||||
}
|
||||
|
||||
export const mockDb = {
|
||||
// === Users ===
|
||||
findUserByEmail(email: string): StoredUser | undefined {
|
||||
return load().users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
},
|
||||
|
||||
findUserById(id: string): StoredUser | undefined {
|
||||
return load().users.find((u) => u.id === id);
|
||||
},
|
||||
|
||||
findOrgById(id: string): Organization | undefined {
|
||||
return load().organizations.find((o) => o.id === id);
|
||||
},
|
||||
|
||||
createUser(input: { email: string; password: string; fullName: string }): User {
|
||||
const db = load();
|
||||
const now = new Date().toISOString();
|
||||
@ -96,7 +106,6 @@ export const mockDb = {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const user: StoredUser = {
|
||||
id: userId,
|
||||
email: input.email,
|
||||
@ -107,13 +116,11 @@ export const mockDb = {
|
||||
updatedAt: now,
|
||||
passwordHash: input.password,
|
||||
};
|
||||
|
||||
db.users.push(user);
|
||||
db.organizations.push(org);
|
||||
save(db);
|
||||
return stripHash(user);
|
||||
},
|
||||
|
||||
updateUser(
|
||||
id: string,
|
||||
patch: Partial<Pick<User, "fullName" | "email" | "signature">>,
|
||||
@ -122,16 +129,16 @@ export const mockDb = {
|
||||
const idx = db.users.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return undefined;
|
||||
const existing = db.users[idx]!;
|
||||
const updated: StoredUser = {
|
||||
...existing,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const updated: StoredUser = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
||||
db.users[idx] = updated;
|
||||
save(db);
|
||||
return stripHash(updated);
|
||||
},
|
||||
|
||||
// === Organizations ===
|
||||
findOrgById(id: string): Organization | undefined {
|
||||
return load().organizations.find((o) => o.id === id);
|
||||
},
|
||||
updateOrg(
|
||||
id: string,
|
||||
patch: Partial<Pick<Organization, "name" | "siret" | "monthlyVolumeBucket">>,
|
||||
@ -140,12 +147,46 @@ export const mockDb = {
|
||||
const idx = db.organizations.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return undefined;
|
||||
const existing = db.organizations[idx]!;
|
||||
const updated: Organization = {
|
||||
const updated: Organization = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
||||
db.organizations[idx] = updated;
|
||||
save(db);
|
||||
return updated;
|
||||
},
|
||||
|
||||
// === Clients ===
|
||||
findClientById(orgId: string, id: string): Client | undefined {
|
||||
return load().clients.find((c) => c.organizationId === orgId && c.id === id);
|
||||
},
|
||||
|
||||
// === Plans ===
|
||||
findPlanById(orgId: string, id: string): Plan | undefined {
|
||||
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
|
||||
},
|
||||
|
||||
// === Invoices ===
|
||||
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
||||
return load().invoices.filter((i) => i.organizationId === orgId);
|
||||
},
|
||||
findInvoiceById(orgId: string, id: string): StoredInvoice | undefined {
|
||||
return load().invoices.find((i) => i.organizationId === orgId && i.id === id);
|
||||
},
|
||||
updateInvoice(
|
||||
orgId: string,
|
||||
id: string,
|
||||
patch: Partial<StoredInvoice>,
|
||||
): StoredInvoice | undefined {
|
||||
const db = load();
|
||||
const idx = db.invoices.findIndex(
|
||||
(i) => i.organizationId === orgId && i.id === id,
|
||||
);
|
||||
if (idx === -1) return undefined;
|
||||
const existing = db.invoices[idx]!;
|
||||
const updated: StoredInvoice = {
|
||||
...existing,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.organizations[idx] = updated;
|
||||
db.invoices[idx] = updated;
|
||||
save(db);
|
||||
return updated;
|
||||
},
|
||||
|
||||
121
apps/web/src/mocks/handlers/dashboard.ts
Normal file
121
apps/web/src/mocks/handlers/dashboard.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import { mockDb } from "../db";
|
||||
import { userIdFromAuthHeader } from "./auth";
|
||||
|
||||
const apiBase = "*/api/v1";
|
||||
|
||||
function unauthenticated() {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Données dashboard simulées — calibrées sur le wireframe 4.1. */
|
||||
const SAMPLE_KPIS = {
|
||||
rubisCount: 184,
|
||||
rubisThisMonth: 124,
|
||||
hoursLiberatedThisMonth: 1240, // minutes
|
||||
encaisseCents: 1_432_000,
|
||||
encaisseDeltaCents: 280_000,
|
||||
dsoDays: 38,
|
||||
dsoDeltaDays: -6,
|
||||
factureToRelance: 8,
|
||||
factureInRelance: 11,
|
||||
factureNewToday: 3,
|
||||
miseEnDemeurePending: 2,
|
||||
monthlyGoalProgress: 80,
|
||||
percentile: 15,
|
||||
};
|
||||
|
||||
const SAMPLE_ACTIVITY = [
|
||||
{
|
||||
id: "evt_1",
|
||||
kind: "relance_sent",
|
||||
at: todayAt(11, 14),
|
||||
label: "Relance J+3 envoyée à <b>Atelier Durand</b>",
|
||||
},
|
||||
{
|
||||
id: "evt_2",
|
||||
kind: "invoice_paid",
|
||||
at: todayAt(10, 2),
|
||||
label: "Facture <b>F-2026-0035</b> marquée encaissée",
|
||||
},
|
||||
{
|
||||
id: "evt_3",
|
||||
kind: "invoice_imported",
|
||||
at: todayAt(9, 48),
|
||||
label: "<b>3 factures</b> importées et OCRisées",
|
||||
},
|
||||
{
|
||||
id: "evt_4",
|
||||
kind: "warning_drafted",
|
||||
at: todayAt(9, 30),
|
||||
label: "Brouillon mise en demeure prêt — <b>Cabinet Rousseau</b>",
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLE_LATE_PAYERS = [
|
||||
{ clientId: "cli_rousseau", name: "Cabinet Rousseau", lateInvoicesCount: 2 },
|
||||
{ clientId: "cli_durand", name: "Atelier Durand", lateInvoicesCount: 2 },
|
||||
{ clientId: "cli_lemoine", name: "Garage Lemoine", lateInvoicesCount: 1 },
|
||||
];
|
||||
|
||||
function todayAt(hours: number, minutes: number): string {
|
||||
const d = new Date();
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export const dashboardHandlers = [
|
||||
// GET /api/v1/dashboard/kpis
|
||||
http.get(`${apiBase}/dashboard/kpis`, ({ request }) => {
|
||||
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
||||
if (!userId) return unauthenticated();
|
||||
|
||||
// L'utilisateur démo a accès aux KPIs simulés. Les autres (signups frais)
|
||||
// démarrent à zéro pour donner un sentiment de "monde vierge".
|
||||
const user = mockDb.findUserById(userId);
|
||||
if (user?.email === "demo@rubis.fr") {
|
||||
return HttpResponse.json({ data: SAMPLE_KPIS });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
rubisCount: 0,
|
||||
rubisThisMonth: 0,
|
||||
hoursLiberatedThisMonth: 0,
|
||||
encaisseCents: 0,
|
||||
encaisseDeltaCents: 0,
|
||||
dsoDays: 0,
|
||||
dsoDeltaDays: 0,
|
||||
factureToRelance: 0,
|
||||
factureInRelance: 0,
|
||||
factureNewToday: 0,
|
||||
miseEnDemeurePending: 0,
|
||||
monthlyGoalProgress: 0,
|
||||
percentile: undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /api/v1/dashboard/activity
|
||||
http.get(`${apiBase}/dashboard/activity`, ({ request }) => {
|
||||
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
||||
if (!userId) return unauthenticated();
|
||||
const user = mockDb.findUserById(userId);
|
||||
return HttpResponse.json({
|
||||
data: user?.email === "demo@rubis.fr" ? SAMPLE_ACTIVITY : [],
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /api/v1/dashboard/top-late
|
||||
http.get(`${apiBase}/dashboard/top-late`, ({ request }) => {
|
||||
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
||||
if (!userId) return unauthenticated();
|
||||
const user = mockDb.findUserById(userId);
|
||||
return HttpResponse.json({
|
||||
data: user?.email === "demo@rubis.fr" ? SAMPLE_LATE_PAYERS : [],
|
||||
});
|
||||
}),
|
||||
];
|
||||
@ -1,5 +1,12 @@
|
||||
import { authHandlers } from "./auth";
|
||||
import { onboardingHandlers } from "./onboarding";
|
||||
import { dashboardHandlers } from "./dashboard";
|
||||
import { invoiceHandlers } from "./invoices";
|
||||
|
||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||
export const handlers = [...authHandlers, ...onboardingHandlers];
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...onboardingHandlers,
|
||||
...dashboardHandlers,
|
||||
...invoiceHandlers,
|
||||
];
|
||||
|
||||
229
apps/web/src/mocks/handlers/invoices.ts
Normal file
229
apps/web/src/mocks/handlers/invoices.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { invoiceListFiltersSchema } from "@rubis/shared";
|
||||
import type { Client, InvoiceStatus, Plan, PlanStep } from "@rubis/shared";
|
||||
|
||||
import { mockDb, type StoredInvoice } from "../db";
|
||||
import { userIdFromAuthHeader } from "./auth";
|
||||
|
||||
const apiBase = "*/api/v1";
|
||||
|
||||
function unauthenticated() {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "not_found", message: "Facture introuvable" }] },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
function authedOrgId(authHeader: string | null): string | undefined {
|
||||
const userId = userIdFromAuthHeader(authHeader);
|
||||
if (!userId) return undefined;
|
||||
return mockDb.findUserById(userId)?.organizationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la timeline d'une facture en composant les étapes du plan
|
||||
* avec l'état courant. Très simplifié pour V1 :
|
||||
* - étapes dont sendDay <= aujourd'hui : "past" (envoyées)
|
||||
* - étape actuelle (la prochaine future) : "current"
|
||||
* - étapes futures : "future"
|
||||
*
|
||||
* On ajoute un événement initial "facture émise".
|
||||
*/
|
||||
function buildTimeline(
|
||||
invoice: StoredInvoice,
|
||||
plan: Plan | null,
|
||||
): Array<{
|
||||
id: string;
|
||||
state: "past" | "current" | "future";
|
||||
when: string;
|
||||
what: string;
|
||||
detail?: string;
|
||||
}> {
|
||||
const events: ReturnType<typeof buildTimeline> = [
|
||||
{
|
||||
id: `${invoice.id}__issued`,
|
||||
state: "past",
|
||||
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
|
||||
what: "Importée · OCR validée",
|
||||
},
|
||||
];
|
||||
|
||||
if (plan && invoice.status !== "paid" && invoice.status !== "cancelled") {
|
||||
const dueMs = new Date(invoice.dueDate).getTime();
|
||||
const nowMs = Date.now();
|
||||
let currentSet = false;
|
||||
|
||||
for (const step of plan.steps) {
|
||||
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000;
|
||||
const sendDate = new Date(sendMs);
|
||||
const labelStep = `J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays} — Étape ${step.order + 1}`;
|
||||
|
||||
let state: "past" | "current" | "future";
|
||||
if (sendMs < nowMs) {
|
||||
state = "past";
|
||||
} else if (!currentSet) {
|
||||
state = "current";
|
||||
currentSet = true;
|
||||
} else {
|
||||
state = "future";
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: `${invoice.id}__step_${step.order}`,
|
||||
state,
|
||||
when: `${formatShortDate(sendDate.toISOString())} · ${labelStep}`,
|
||||
what:
|
||||
state === "past"
|
||||
? `Email envoyé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
|
||||
: state === "current"
|
||||
? `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
|
||||
: `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`,
|
||||
detail: state === "past" ? "Ouvert 1×" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (invoice.status === "paid") {
|
||||
events.push({
|
||||
id: `${invoice.id}__paid`,
|
||||
state: "past",
|
||||
when: `${formatShortDate(invoice.updatedAt)} · facture encaissée`,
|
||||
what: "Marquée encaissée — relances stoppées",
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function formatShortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export const invoiceHandlers = [
|
||||
// GET /api/v1/invoices?status=&q=&clientId=&page=
|
||||
http.get(`${apiBase}/invoices`, ({ request }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
const parsed = invoiceListFiltersSchema.safeParse({
|
||||
...params,
|
||||
page: params.page ? Number(params.page) : undefined,
|
||||
});
|
||||
|
||||
let invoices = mockDb.listInvoicesForOrg(orgId);
|
||||
const filters = parsed.success ? parsed.data : { page: 1 };
|
||||
|
||||
if (filters.status && filters.status !== "all") {
|
||||
invoices = invoices.filter((i) => i.status === (filters.status as InvoiceStatus));
|
||||
}
|
||||
if (filters.clientId) {
|
||||
invoices = invoices.filter((i) => i.clientId === filters.clientId);
|
||||
}
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
invoices = invoices.filter(
|
||||
(i) =>
|
||||
i.numero.toLowerCase().includes(q) ||
|
||||
i.clientName.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// Tri : à relancer / en relance d'abord (les plus actionnables), puis par échéance
|
||||
const STATUS_PRIO: Record<InvoiceStatus, number> = {
|
||||
awaiting_user_confirmation: 0,
|
||||
in_relance: 1,
|
||||
pending: 2,
|
||||
litigation: 3,
|
||||
paid: 4,
|
||||
cancelled: 5,
|
||||
};
|
||||
invoices = [...invoices].sort((a, b) => {
|
||||
const dp = STATUS_PRIO[a.status] - STATUS_PRIO[b.status];
|
||||
if (dp !== 0) return dp;
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
data: invoices,
|
||||
meta: {
|
||||
total: invoices.length,
|
||||
page: filters.page ?? 1,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /api/v1/invoices/counts — compteurs par statut pour les chips
|
||||
http.get(`${apiBase}/invoices/counts`, ({ request }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const invoices = mockDb.listInvoicesForOrg(orgId);
|
||||
const counts = {
|
||||
all: invoices.length,
|
||||
pending: invoices.filter((i) => i.status === "pending").length,
|
||||
in_relance: invoices.filter((i) => i.status === "in_relance").length,
|
||||
awaiting_user_confirmation: invoices.filter(
|
||||
(i) => i.status === "awaiting_user_confirmation",
|
||||
).length,
|
||||
paid: invoices.filter((i) => i.status === "paid").length,
|
||||
litigation: invoices.filter((i) => i.status === "litigation").length,
|
||||
cancelled: invoices.filter((i) => i.status === "cancelled").length,
|
||||
};
|
||||
return HttpResponse.json({ data: counts });
|
||||
}),
|
||||
|
||||
// GET /api/v1/invoices/:id — détail enrichi (client + plan + timeline)
|
||||
http.get(`${apiBase}/invoices/:id`, ({ request, params }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const id = params.id as string;
|
||||
const invoice = mockDb.findInvoiceById(orgId, id);
|
||||
if (!invoice) return notFound();
|
||||
|
||||
const client = mockDb.findClientById(orgId, invoice.clientId) as Client;
|
||||
const plan = invoice.planId ? mockDb.findPlanById(orgId, invoice.planId) ?? null : null;
|
||||
const timeline = buildTimeline(invoice, plan);
|
||||
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
...invoice,
|
||||
client,
|
||||
plan,
|
||||
timeline,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// POST /api/v1/invoices/:id/mark-paid
|
||||
http.post(`${apiBase}/invoices/:id/mark-paid`, ({ request, params }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const id = params.id as string;
|
||||
const invoice = mockDb.findInvoiceById(orgId, id);
|
||||
if (!invoice) return notFound();
|
||||
|
||||
const updated = mockDb.updateInvoice(orgId, id, {
|
||||
status: "paid",
|
||||
// +1 rubis bonus quand on encaisse — gamification
|
||||
rubisEarned: invoice.rubisEarned + 1,
|
||||
});
|
||||
return HttpResponse.json({ data: updated });
|
||||
}),
|
||||
];
|
||||
|
||||
// Suppress unused warnings for the imported types we re-export indirectly.
|
||||
export type { PlanStep };
|
||||
421
apps/web/src/mocks/seed.ts
Normal file
421
apps/web/src/mocks/seed.ts
Normal file
@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Seed des données pour l'utilisateur démo.
|
||||
*
|
||||
* On factorise le seed dans son propre fichier pour ne pas alourdir db.ts
|
||||
* avec 200 lignes de données. Calibré sur les wireframes pour donner un
|
||||
* dashboard / liste réalistes au premier login.
|
||||
*/
|
||||
import type { Client, Invoice, Plan } from "@rubis/shared";
|
||||
|
||||
const NOW = new Date("2026-05-06T08:30:00.000Z");
|
||||
|
||||
function isoFromOffset(daysOffset: number, hour = 9): string {
|
||||
const d = new Date(NOW);
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
const ORG = "org_demo";
|
||||
|
||||
export const SEED_CLIENTS: Client[] = [
|
||||
{
|
||||
id: "cli_martin",
|
||||
organizationId: ORG,
|
||||
name: "Boulangerie Martin SARL",
|
||||
email: "compta@boulangerie-martin.fr",
|
||||
phone: "+33 1 23 45 67 89",
|
||||
address: "12 rue du Pain, 75011 Paris",
|
||||
notes: null,
|
||||
createdAt: isoFromOffset(-90),
|
||||
updatedAt: isoFromOffset(-2),
|
||||
},
|
||||
{
|
||||
id: "cli_durand",
|
||||
organizationId: ORG,
|
||||
name: "Atelier Durand",
|
||||
email: "contact@atelier-durand.fr",
|
||||
phone: null,
|
||||
address: null,
|
||||
notes: "Le client a confirmé la réception le 14/04 par téléphone — relance ferme inutile.",
|
||||
createdAt: isoFromOffset(-120),
|
||||
updatedAt: isoFromOffset(-3),
|
||||
},
|
||||
{
|
||||
id: "cli_rousseau",
|
||||
organizationId: ORG,
|
||||
name: "Cabinet Rousseau",
|
||||
email: "facturation@cabinet-rousseau.fr",
|
||||
phone: "+33 4 56 78 90 12",
|
||||
address: "8 place de la République, 69002 Lyon",
|
||||
notes: null,
|
||||
createdAt: isoFromOffset(-200),
|
||||
updatedAt: isoFromOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "cli_lemoine",
|
||||
organizationId: ORG,
|
||||
name: "Garage Lemoine",
|
||||
email: "admin@garage-lemoine.fr",
|
||||
phone: null,
|
||||
address: null,
|
||||
notes: null,
|
||||
createdAt: isoFromOffset(-60),
|
||||
updatedAt: isoFromOffset(-5),
|
||||
},
|
||||
{
|
||||
id: "cli_lefevre",
|
||||
organizationId: ORG,
|
||||
name: "Studio Lefèvre",
|
||||
email: "hello@studio-lefevre.com",
|
||||
phone: null,
|
||||
address: null,
|
||||
notes: null,
|
||||
createdAt: isoFromOffset(-30),
|
||||
updatedAt: isoFromOffset(-1),
|
||||
},
|
||||
];
|
||||
|
||||
export const SEED_PLANS: Plan[] = [
|
||||
{
|
||||
id: "plan_standard",
|
||||
organizationId: ORG,
|
||||
slug: "standard-30j",
|
||||
name: "Standard B2B",
|
||||
description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.",
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: "step_std_1",
|
||||
order: 0,
|
||||
offsetDays: 3,
|
||||
tone: "amical",
|
||||
subject: "Petit rappel — facture {{numero}}",
|
||||
body: "Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_std_2",
|
||||
order: 1,
|
||||
offsetDays: 10,
|
||||
tone: "courtois",
|
||||
subject: "Relance — facture {{numero}} en retard",
|
||||
body: "Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_std_3",
|
||||
order: 2,
|
||||
offsetDays: 25,
|
||||
tone: "ferme",
|
||||
subject: "Mise en demeure — facture {{numero}}",
|
||||
body: "Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
createdAt: isoFromOffset(-365),
|
||||
updatedAt: isoFromOffset(-30),
|
||||
},
|
||||
{
|
||||
id: "plan_rapide",
|
||||
organizationId: ORG,
|
||||
slug: "rapide-15j",
|
||||
name: "Rapide",
|
||||
description: "Cadence resserrée pour les factures récurrentes ou les délais courts.",
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: "step_rap_1",
|
||||
order: 0,
|
||||
offsetDays: 1,
|
||||
tone: "amical",
|
||||
subject: "Facture {{numero}} échue",
|
||||
body: "Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_rap_2",
|
||||
order: 1,
|
||||
offsetDays: 7,
|
||||
tone: "courtois",
|
||||
subject: "Relance facture {{numero}}",
|
||||
body: "La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_rap_3",
|
||||
order: 2,
|
||||
offsetDays: 15,
|
||||
tone: "ferme",
|
||||
subject: "Mise en demeure {{numero}}",
|
||||
body: "Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}",
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
createdAt: isoFromOffset(-365),
|
||||
updatedAt: isoFromOffset(-365),
|
||||
},
|
||||
{
|
||||
id: "plan_patient",
|
||||
organizationId: ORG,
|
||||
slug: "patient-60j",
|
||||
name: "Patient",
|
||||
description: "Pour les clients de longue date. On laisse respirer avant de relancer.",
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: "step_pat_1",
|
||||
order: 0,
|
||||
offsetDays: 15,
|
||||
tone: "amical",
|
||||
subject: "Facture {{numero}}",
|
||||
body: "Bonjour, simple rappel.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_pat_2",
|
||||
order: 1,
|
||||
offsetDays: 30,
|
||||
tone: "courtois",
|
||||
subject: "Relance facture {{numero}}",
|
||||
body: "Merci de régulariser dans les meilleurs délais.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
],
|
||||
createdAt: isoFromOffset(-365),
|
||||
updatedAt: isoFromOffset(-365),
|
||||
},
|
||||
{
|
||||
id: "plan_ferme",
|
||||
organizationId: ORG,
|
||||
slug: "ferme-7j",
|
||||
name: "Ferme",
|
||||
description: "Cadence stricte pour les clients à risque ou les retards récurrents.",
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: "step_fer_1",
|
||||
order: 0,
|
||||
offsetDays: 1,
|
||||
tone: "courtois",
|
||||
subject: "Facture {{numero}}",
|
||||
body: "Premier rappel.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_fer_2",
|
||||
order: 1,
|
||||
offsetDays: 5,
|
||||
tone: "ferme",
|
||||
subject: "Relance ferme {{numero}}",
|
||||
body: "Le règlement est attendu sous 48h.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
id: "step_fer_3",
|
||||
order: 2,
|
||||
offsetDays: 10,
|
||||
tone: "mise_en_demeure",
|
||||
subject: "Mise en demeure {{numero}}",
|
||||
body: "Mise en demeure formelle.\n\n{{signature}}",
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
createdAt: isoFromOffset(-365),
|
||||
updatedAt: isoFromOffset(-365),
|
||||
},
|
||||
];
|
||||
|
||||
type SeedInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string };
|
||||
|
||||
export const SEED_INVOICES: SeedInvoice[] = [
|
||||
// À relancer (échéance future)
|
||||
{
|
||||
id: "inv_001",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_martin",
|
||||
clientName: "Boulangerie Martin SARL",
|
||||
numero: "F-2026-0042",
|
||||
amountTtcCents: 124_000,
|
||||
issueDate: isoFromOffset(-15),
|
||||
dueDate: isoFromOffset(10),
|
||||
status: "pending",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 0,
|
||||
createdAt: isoFromOffset(-15),
|
||||
updatedAt: isoFromOffset(-15),
|
||||
},
|
||||
{
|
||||
id: "inv_002",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_lefevre",
|
||||
clientName: "Studio Lefèvre",
|
||||
numero: "F-2026-0044",
|
||||
amountTtcCents: 245_000,
|
||||
issueDate: isoFromOffset(-10),
|
||||
dueDate: isoFromOffset(20),
|
||||
status: "pending",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 0,
|
||||
createdAt: isoFromOffset(-10),
|
||||
updatedAt: isoFromOffset(-10),
|
||||
},
|
||||
{
|
||||
id: "inv_003",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_lemoine",
|
||||
clientName: "Garage Lemoine",
|
||||
numero: "F-2026-0045",
|
||||
amountTtcCents: 89_000,
|
||||
issueDate: isoFromOffset(-5),
|
||||
dueDate: isoFromOffset(25),
|
||||
status: "pending",
|
||||
planId: "plan_patient",
|
||||
planName: "Patient",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 0,
|
||||
createdAt: isoFromOffset(-5),
|
||||
updatedAt: isoFromOffset(-5),
|
||||
},
|
||||
// En relance (échéance passée, étape envoyée)
|
||||
{
|
||||
id: "inv_004",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_durand",
|
||||
clientName: "Atelier Durand",
|
||||
numero: "F-2026-0039",
|
||||
amountTtcCents: 360_000,
|
||||
issueDate: isoFromOffset(-34),
|
||||
dueDate: isoFromOffset(-4),
|
||||
status: "in_relance",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: "Client a confirmé la réception le 14/04 par téléphone.",
|
||||
rubisEarned: 1,
|
||||
statusLabel: "Relance J+3 envoyée",
|
||||
createdAt: isoFromOffset(-34),
|
||||
updatedAt: isoFromOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "inv_005",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_durand",
|
||||
clientName: "Atelier Durand",
|
||||
numero: "F-2026-0036",
|
||||
amountTtcCents: 180_000,
|
||||
issueDate: isoFromOffset(-50),
|
||||
dueDate: isoFromOffset(-20),
|
||||
status: "in_relance",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 2,
|
||||
statusLabel: "Relance J+10 envoyée",
|
||||
createdAt: isoFromOffset(-50),
|
||||
updatedAt: isoFromOffset(-10),
|
||||
},
|
||||
{
|
||||
id: "inv_006",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_martin",
|
||||
clientName: "Boulangerie Martin SARL",
|
||||
numero: "F-2026-0033",
|
||||
amountTtcCents: 95_500,
|
||||
issueDate: isoFromOffset(-40),
|
||||
dueDate: isoFromOffset(-10),
|
||||
status: "in_relance",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 1,
|
||||
statusLabel: "Relance J+10 demain",
|
||||
createdAt: isoFromOffset(-40),
|
||||
updatedAt: isoFromOffset(-3),
|
||||
},
|
||||
// À valider (mise en demeure)
|
||||
{
|
||||
id: "inv_007",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_rousseau",
|
||||
clientName: "Cabinet Rousseau",
|
||||
numero: "F-2026-0028",
|
||||
amountTtcCents: 85_000,
|
||||
issueDate: isoFromOffset(-65),
|
||||
dueDate: isoFromOffset(-35),
|
||||
status: "awaiting_user_confirmation",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 2,
|
||||
statusLabel: "Mise en demeure prête",
|
||||
createdAt: isoFromOffset(-65),
|
||||
updatedAt: isoFromOffset(0),
|
||||
},
|
||||
// Encaissées
|
||||
{
|
||||
id: "inv_008",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_lemoine",
|
||||
clientName: "Garage Lemoine",
|
||||
numero: "F-2026-0035",
|
||||
amountTtcCents: 420_000,
|
||||
issueDate: isoFromOffset(-45),
|
||||
dueDate: isoFromOffset(-15),
|
||||
status: "paid",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 2,
|
||||
createdAt: isoFromOffset(-45),
|
||||
updatedAt: isoFromOffset(0),
|
||||
},
|
||||
{
|
||||
id: "inv_009",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_lefevre",
|
||||
clientName: "Studio Lefèvre",
|
||||
numero: "F-2026-0030",
|
||||
amountTtcCents: 156_000,
|
||||
issueDate: isoFromOffset(-60),
|
||||
dueDate: isoFromOffset(-30),
|
||||
status: "paid",
|
||||
planId: "plan_standard",
|
||||
planName: "Standard B2B",
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
rubisEarned: 1,
|
||||
createdAt: isoFromOffset(-60),
|
||||
updatedAt: isoFromOffset(-25),
|
||||
},
|
||||
// Litige
|
||||
{
|
||||
id: "inv_010",
|
||||
organizationId: ORG,
|
||||
clientId: "cli_rousseau",
|
||||
clientName: "Cabinet Rousseau",
|
||||
numero: "F-2026-0019",
|
||||
amountTtcCents: 1_200_000,
|
||||
issueDate: isoFromOffset(-100),
|
||||
dueDate: isoFromOffset(-70),
|
||||
status: "litigation",
|
||||
planId: null,
|
||||
planName: null,
|
||||
pdfStorageKey: null,
|
||||
notes: "Litige en cours — passé en contentieux.",
|
||||
rubisEarned: 3,
|
||||
createdAt: isoFromOffset(-100),
|
||||
updatedAt: isoFromOffset(-15),
|
||||
},
|
||||
];
|
||||
34
apps/web/src/routes/_app.tsx
Normal file
34
apps/web/src/routes/_app.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
import { authStore } from "@/lib/auth";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
|
||||
/**
|
||||
* `_app` — layout pathless pour l'app authentifiée.
|
||||
* URL des enfants : /, /factures, /plans, /clients, /parametres
|
||||
*
|
||||
* Garde d'auth + onboarding :
|
||||
* - Pas authentifié → /login (avec redirect)
|
||||
* - Authentifié mais signature null → onboarding pas terminé → /onboarding/compte
|
||||
*/
|
||||
export const Route = createFileRoute("/_app")({
|
||||
beforeLoad: ({ location }) => {
|
||||
if (!authStore.isAuthenticated()) {
|
||||
throw redirect({ to: "/login", search: { redirect: location.href } });
|
||||
}
|
||||
// Onboarding incomplet : on a un user mais pas de signature → step 3 pas faite.
|
||||
// Côté API "vrai", on s'appuiera plutôt sur `organization.onboardingCompletedAt`.
|
||||
if (authStore.user && authStore.user.signature === null) {
|
||||
throw redirect({ to: "/onboarding/compte" });
|
||||
}
|
||||
},
|
||||
component: AppRouteComponent,
|
||||
});
|
||||
|
||||
function AppRouteComponent() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/routes/_app/clients.tsx
Normal file
39
apps/web/src/routes/_app/clients.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export const Route = createFileRoute("/_app/clients")({
|
||||
component: ClientsPage,
|
||||
});
|
||||
|
||||
function ClientsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
||||
Clients
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] text-ink-3">
|
||||
Vos clients facturés, leurs coordonnées, leur historique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
draft
|
||||
icon={<Users size={36} strokeWidth={1.5} aria-hidden="true" />}
|
||||
title={
|
||||
<>
|
||||
Pas encore de <em className="text-rubis">clients</em> à afficher.
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
Le carnet client se remplit automatiquement à chaque facture
|
||||
importée. La vue dédiée arrive dans une prochaine itération.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
apps/web/src/routes/_app/factures.tsx
Normal file
208
apps/web/src/routes/_app/factures.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
invoiceListFiltersSchema,
|
||||
type InvoiceStatus,
|
||||
} from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
import { Dropzone } from "@/components/factures/Dropzone";
|
||||
import { FilterChips, type FilterOption } from "@/components/factures/FilterChips";
|
||||
import {
|
||||
InvoiceTable,
|
||||
type InvoiceListItem,
|
||||
} from "@/components/factures/InvoiceTable";
|
||||
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
||||
|
||||
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
|
||||
const FILTER_KEYS = [
|
||||
"all",
|
||||
"pending",
|
||||
"in_relance",
|
||||
"awaiting_user_confirmation",
|
||||
"paid",
|
||||
"litigation",
|
||||
] as const;
|
||||
type FilterKey = (typeof FILTER_KEYS)[number];
|
||||
|
||||
/** Sous-ensemble du schéma shared, adapté aux clés UI. */
|
||||
const searchSchema = invoiceListFiltersSchema.extend({
|
||||
status: z.enum(FILTER_KEYS).optional().default("all"),
|
||||
});
|
||||
|
||||
type StatusCounts = {
|
||||
all: number;
|
||||
pending: number;
|
||||
in_relance: number;
|
||||
awaiting_user_confirmation: number;
|
||||
paid: number;
|
||||
litigation: number;
|
||||
cancelled: number;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/_app/factures")({
|
||||
validateSearch: searchSchema,
|
||||
component: FacturesPage,
|
||||
loader: ({ context }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.invoices.list({}),
|
||||
queryFn: () => api.get<InvoiceListItem[]>("/api/v1/invoices"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function FacturesPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
const { data: invoices = [], isPending } = useQuery({
|
||||
queryKey: queryKeys.invoices.list({
|
||||
status: search.status as InvoiceStatus | "all" | undefined,
|
||||
q: search.q,
|
||||
clientId: search.clientId,
|
||||
page: search.page,
|
||||
}),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search.status && search.status !== "all") params.set("status", search.status);
|
||||
if (search.q) params.set("q", search.q);
|
||||
if (search.clientId) params.set("clientId", search.clientId);
|
||||
const qs = params.toString();
|
||||
return api.get<InvoiceListItem[]>(
|
||||
`/api/v1/invoices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: counts } = useQuery({
|
||||
queryKey: ["invoices", "counts"] as const,
|
||||
queryFn: () => api.get<StatusCounts>("/api/v1/invoices/counts"),
|
||||
});
|
||||
|
||||
// Empty state global = aucune facture du tout (pas juste filtre vide).
|
||||
// Pour l'instant on l'estime via counts.all === 0.
|
||||
const totalInvoices = counts?.all ?? null;
|
||||
const isFilteredEmpty = invoices.length === 0 && totalInvoices !== 0;
|
||||
|
||||
if (totalInvoices === 0) {
|
||||
return <FacturesEmpty />;
|
||||
}
|
||||
|
||||
const filterOptions: ReadonlyArray<FilterOption<FilterKey>> = [
|
||||
{ key: "all", label: "Toutes", count: counts?.all },
|
||||
{ key: "pending", label: "À relancer", count: counts?.pending },
|
||||
{ key: "in_relance", label: "En relance", count: counts?.in_relance },
|
||||
{
|
||||
key: "awaiting_user_confirmation",
|
||||
label: "À valider",
|
||||
count: counts?.awaiting_user_confirmation,
|
||||
},
|
||||
{ key: "paid", label: "Encaissées", count: counts?.paid },
|
||||
{ key: "litigation", label: "Litige", count: counts?.litigation },
|
||||
];
|
||||
|
||||
const handleFilter = (key: FilterKey): void => {
|
||||
void navigate({
|
||||
to: "/factures",
|
||||
search: (prev) => ({ ...prev, status: key, page: 1 }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<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>
|
||||
|
||||
<FilterChips
|
||||
options={filterOptions}
|
||||
value={(search.status as FilterKey | undefined) ?? "all"}
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
|
||||
{isFilteredEmpty ? (
|
||||
<FilteredEmpty onReset={() => handleFilter("all")} />
|
||||
) : isPending ? (
|
||||
<SkeletonRows />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden lg:block">
|
||||
<InvoiceTable invoices={invoices} />
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<InvoiceCardList invoices={invoices} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty state global : pas une seule facture. La dropzone EST l'écran. */
|
||||
function FacturesEmpty() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||
<em className="text-rubis">Première</em> facture ?
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] text-ink-3">
|
||||
Glissez vos PDF ici, l'OCR se charge du reste. Vous validez
|
||||
en 30 secondes.
|
||||
</p>
|
||||
</div>
|
||||
<Dropzone variant="full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty state filtré : on a des factures mais pas dans ce filtre. */
|
||||
function FilteredEmpty({ onReset }: { onReset: () => void }) {
|
||||
return (
|
||||
<div className="rounded-card border border-dashed border-line bg-cream-2/30 px-6 py-12 text-center">
|
||||
<p className="font-display text-[18px] font-semibold text-ink">
|
||||
Rien dans <em className="text-rubis">ce filtre</em>.
|
||||
</p>
|
||||
<p className="mt-2 text-[13.5px] text-ink-2">
|
||||
Aucune facture ne correspond. C'est plutôt bon signe.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="mt-4 text-[13px] font-semibold text-rubis underline-offset-4 hover:underline"
|
||||
>
|
||||
Voir toutes les factures
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows() {
|
||||
return (
|
||||
<div className="rounded-card border border-line bg-white">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 px-5 py-4 border-t border-line first:border-t-0 animate-pulse"
|
||||
>
|
||||
<div className="h-3 flex-1 rounded bg-cream-2" />
|
||||
<div className="h-3 w-20 rounded bg-cream-2" />
|
||||
<div className="h-3 w-24 rounded bg-cream-2" />
|
||||
<div className="h-3 w-28 rounded bg-cream-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
apps/web/src/routes/_app/factures_.$id.tsx
Normal file
211
apps/web/src/routes/_app/factures_.$id.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Client, Invoice, Plan } from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
formatEuros,
|
||||
formatDate,
|
||||
formatDueDelta,
|
||||
isOverdue,
|
||||
} from "@/lib/format";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { Timeline, type TimelineEvent } from "@/components/ui/Timeline";
|
||||
import { Textarea } from "@/components/ui/Textarea";
|
||||
|
||||
type InvoiceDetail = Invoice & {
|
||||
client: Client;
|
||||
plan: Plan | null;
|
||||
timeline: TimelineEvent[];
|
||||
/** Override du label statut (ex: "Relance J+3 envoyée"). */
|
||||
statusLabel?: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/_app/factures_/$id")({
|
||||
component: InvoiceDetailPage,
|
||||
loader: ({ context, params }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.invoices.detail(params.id),
|
||||
queryFn: () => api.get<InvoiceDetail>(`/api/v1/invoices/${params.id}`),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function InvoiceDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: invoice, isPending, isError } = useQuery({
|
||||
queryKey: queryKeys.invoices.detail(id),
|
||||
queryFn: () => api.get<InvoiceDetail>(`/api/v1/invoices/${id}`),
|
||||
});
|
||||
|
||||
const markPaidMutation = useMutation({
|
||||
mutationFn: () => api.post<InvoiceDetail>(`/api/v1/invoices/${id}/mark-paid`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() });
|
||||
toast.success("Encaissée. + 1 rubis bien mérité.");
|
||||
void navigate({ to: "/factures" });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("On n'a pas pu marquer la facture. Réessayez.");
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="font-display text-[20px] font-semibold text-ink">
|
||||
Facture introuvable.
|
||||
</p>
|
||||
<Link
|
||||
to="/factures"
|
||||
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
|
||||
>
|
||||
← Retour aux factures
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending || !invoice) {
|
||||
return <InvoiceDetailSkeleton />;
|
||||
}
|
||||
|
||||
const overdue = isOverdue(invoice.dueDate);
|
||||
const isPaid = invoice.status === "paid";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
to="/factures"
|
||||
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
|
||||
>
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Factures
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow>{invoice.client.name}</Eyebrow>
|
||||
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
||||
Facture {invoice.numero}
|
||||
</h1>
|
||||
<p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[14px] text-ink-2">
|
||||
<span className="font-display text-[18px] font-semibold text-ink tabular-nums">
|
||||
{formatEuros(invoice.amountTtcCents)}
|
||||
</span>
|
||||
<span className="text-ink-3">·</span>
|
||||
<span>
|
||||
échue le{" "}
|
||||
<span className={overdue ? "font-semibold text-ink" : ""}>
|
||||
{formatDate(invoice.dueDate)}
|
||||
</span>{" "}
|
||||
<span
|
||||
className={`tabular-nums ${
|
||||
overdue ? "font-semibold text-rubis-deep" : "text-ink-3"
|
||||
}`}
|
||||
>
|
||||
({formatDueDelta(invoice.dueDate)})
|
||||
</span>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={invoice.status}
|
||||
label={invoice.statusLabel}
|
||||
className="ml-1"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isPaid && invoice.status !== "litigation" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={markPaidMutation.isPending}
|
||||
onClick={() => markPaidMutation.mutate()}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" /> Marquer encaissée
|
||||
</Button>
|
||||
<Button size="sm" disabled>
|
||||
<Send size={14} aria-hidden="true" /> Relancer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
|
||||
{/* Timeline */}
|
||||
<Card padding="md">
|
||||
<Eyebrow tone="ink">
|
||||
Timeline
|
||||
{invoice.plan && (
|
||||
<span className="text-ink-3"> · plan {invoice.plan.name}</span>
|
||||
)}
|
||||
</Eyebrow>
|
||||
<Timeline events={invoice.timeline} className="mt-5" />
|
||||
</Card>
|
||||
|
||||
{/* Sidepanel : client + notes */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card padding="md">
|
||||
<Eyebrow tone="ink">Client</Eyebrow>
|
||||
<p className="mt-3 font-display text-[16px] font-semibold text-ink">
|
||||
{invoice.client.name}
|
||||
</p>
|
||||
{invoice.client.email && (
|
||||
<p className="mt-1 text-[13.5px] text-ink-2 truncate">
|
||||
{invoice.client.email}
|
||||
</p>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
<p className="mt-0.5 text-[13.5px] text-ink-2">
|
||||
{invoice.client.phone}
|
||||
</p>
|
||||
)}
|
||||
{invoice.client.address && (
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-ink-3">
|
||||
{invoice.client.address}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Eyebrow tone="ink">Notes internes</Eyebrow>
|
||||
<Textarea
|
||||
defaultValue={invoice.notes ?? invoice.client.notes ?? ""}
|
||||
placeholder="Notes privées sur ce client ou cette facture…"
|
||||
rows={5}
|
||||
className="mt-3 bg-cream-2/40 border-0 focus:bg-white"
|
||||
/>
|
||||
<p className="mt-2 text-[11.5px] text-ink-3 italic">
|
||||
Visibles uniquement par vous. Pas envoyées au client.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
<div className="h-3 w-20 rounded bg-cream-2" />
|
||||
<div className="h-8 w-2/3 rounded bg-cream-2" />
|
||||
<div className="h-4 w-1/2 rounded bg-cream-2" />
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div className="h-72 rounded-card bg-cream-2" />
|
||||
<div className="h-72 rounded-card bg-cream-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/routes/_app/index.tsx
Normal file
142
apps/web/src/routes/_app/index.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Camera, Plus, ArrowDownRight } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { formatEuros } from "@/lib/format";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { RubisHero } from "@/components/dashboard/RubisHero";
|
||||
import { KpiCard } from "@/components/dashboard/KpiCard";
|
||||
import {
|
||||
ActivityFeed,
|
||||
type ActivityEvent,
|
||||
} from "@/components/dashboard/ActivityFeed";
|
||||
import {
|
||||
TopLatePayers,
|
||||
type LatePayer,
|
||||
} from "@/components/dashboard/TopLatePayers";
|
||||
|
||||
type DashboardKpis = {
|
||||
rubisCount: number;
|
||||
rubisThisMonth: number;
|
||||
hoursLiberatedThisMonth: number;
|
||||
encaisseCents: number;
|
||||
encaisseDeltaCents: number;
|
||||
dsoDays: number;
|
||||
dsoDeltaDays: number;
|
||||
factureToRelance: number;
|
||||
factureInRelance: number;
|
||||
factureNewToday: number;
|
||||
miseEnDemeurePending: number;
|
||||
monthlyGoalProgress: number;
|
||||
percentile?: number;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/_app/")({
|
||||
component: DashboardPage,
|
||||
loader: ({ context }) => {
|
||||
// Préchauffe le cache pour éviter le flash KPIs vides au mount.
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.dashboard.kpis(),
|
||||
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: kpis } = useQuery({
|
||||
queryKey: queryKeys.dashboard.kpis(),
|
||||
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
|
||||
});
|
||||
|
||||
const { data: activity = [] } = useQuery({
|
||||
queryKey: queryKeys.dashboard.activity(),
|
||||
queryFn: () => api.get<ActivityEvent[]>("/api/v1/dashboard/activity"),
|
||||
});
|
||||
|
||||
const { data: latePayers = [] } = useQuery({
|
||||
queryKey: ["dashboard", "top-late"] as const,
|
||||
queryFn: () => api.get<LatePayer[]>("/api/v1/dashboard/top-late"),
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button size="sm" variant="secondary" className="flex-1">
|
||||
<Plus size={15} aria-hidden="true" /> Saisir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RubisHero
|
||||
rubisThisMonth={kpis?.rubisThisMonth ?? 0}
|
||||
monthlyGoalProgress={kpis?.monthlyGoalProgress ?? 0}
|
||||
percentile={kpis?.percentile}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Indicateurs clés"
|
||||
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
|
||||
>
|
||||
<KpiCard
|
||||
label="À relancer"
|
||||
value={String(kpis?.factureToRelance ?? 0)}
|
||||
delta={
|
||||
kpis?.factureNewToday
|
||||
? `${kpis.factureNewToday} nouvelle${kpis.factureNewToday > 1 ? "s" : ""} aujourd'hui`
|
||||
: undefined
|
||||
}
|
||||
intent="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="En cours de relance"
|
||||
value={String(kpis?.factureInRelance ?? 0)}
|
||||
delta={
|
||||
kpis?.miseEnDemeurePending
|
||||
? `${kpis.miseEnDemeurePending} mise${kpis.miseEnDemeurePending > 1 ? "s" : ""} en demeure à valider`
|
||||
: undefined
|
||||
}
|
||||
intent="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Encaissé ce mois"
|
||||
value={formatEuros(kpis?.encaisseCents ?? 0)}
|
||||
delta={
|
||||
kpis?.encaisseDeltaCents
|
||||
? `+ ${formatEuros(kpis.encaisseDeltaCents)} vs avril`
|
||||
: undefined
|
||||
}
|
||||
intent="positive"
|
||||
/>
|
||||
<KpiCard
|
||||
label="DSO moyen"
|
||||
value={`${kpis?.dsoDays ?? 0} j`}
|
||||
delta={
|
||||
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
|
||||
? `${
|
||||
kpis.dsoDeltaDays < 0 ? "↘" : "↗"
|
||||
} ${Math.abs(kpis.dsoDeltaDays)} j depuis Rubis`
|
||||
: undefined
|
||||
}
|
||||
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5">
|
||||
<ActivityFeed events={activity} />
|
||||
<TopLatePayers payers={latePayers} />
|
||||
</section>
|
||||
|
||||
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
|
||||
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
|
||||
<ArrowDownRight size={12} aria-hidden="true" />
|
||||
Vos factures se relancent en silence pendant que vous travaillez.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/routes/_app/parametres.tsx
Normal file
36
apps/web/src/routes/_app/parametres.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export const Route = createFileRoute("/_app/parametres")({
|
||||
component: ParametresPage,
|
||||
});
|
||||
|
||||
function ParametresPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] text-ink-3">
|
||||
Compte, entreprise, signature, facturation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
draft
|
||||
icon={<Settings size={36} strokeWidth={1.5} aria-hidden="true" />}
|
||||
title="Bientôt ici."
|
||||
description={
|
||||
<>
|
||||
On va y reposer ce que vous avez rempli à l'onboarding,
|
||||
avec en plus la facturation Rubis et la gestion des invitations
|
||||
(V2). Vous pourrez tout modifier à tout moment.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/routes/_app/plans.tsx
Normal file
40
apps/web/src/routes/_app/plans.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ListChecks } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export const Route = createFileRoute("/_app/plans")({
|
||||
component: PlansPage,
|
||||
});
|
||||
|
||||
function PlansPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
||||
Plans de relance
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] text-ink-3">
|
||||
La cadence avec laquelle Rubis relance pour vous.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
draft
|
||||
icon={<ListChecks size={36} strokeWidth={1.5} aria-hidden="true" />}
|
||||
title={
|
||||
<>
|
||||
Quatre plans <em className="text-rubis">prêts à l'emploi</em>.
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
Bibliothèque + éditeur de plan : cadences (J+3, J+10, J+20…),
|
||||
templates email avec variables, ton qui monte avec le retard.
|
||||
Étape suivante.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { authStore } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* "/" — pas d'UI propre, juste un router.
|
||||
*
|
||||
* Décision actuelle :
|
||||
* - non authentifié → /login
|
||||
* - authentifié → /onboarding/compte (placeholder tant que le layout
|
||||
* `_app` et le dashboard n'existent pas)
|
||||
*
|
||||
* Quand le layout `_app` arrivera, on enverra plutôt vers / côté `_app`,
|
||||
* qui lui-même décidera entre dashboard et onboarding selon
|
||||
* `organization.onboardingCompletedAt`.
|
||||
*/
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: () => {
|
||||
if (!authStore.isAuthenticated()) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
throw redirect({ to: "/onboarding/compte" });
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user