- }
- title={
- <>
- Pas encore de clients à afficher.
- >
- }
- description={
- <>
- Le carnet client se remplit automatiquement à chaque facture
- importée. La vue dédiée arrive dans une prochaine itération.
- >
- }
- />
+ {/* Recherche */}
+
+
+ setSearchInput(e.target.value)}
+ placeholder="Rechercher un client par nom ou email…"
+ className="block w-full rounded-default border border-line bg-white pl-10 pr-3 py-2.5 font-sans text-[14px] text-ink placeholder:text-ink-3 transition-[border-color,box-shadow] duration-150 focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow"
+ />
+
+
+ {isPending ? (
+
+ ) : clients.length === 0 ? (
+ }
+ title={
+ searchInput.length > 0 ? (
+ <>
+ Aucun client ne correspond.
+ >
+ ) : (
+ <>
+ Pas encore de clients.
+ >
+ )
+ }
+ description={
+ searchInput.length > 0
+ ? "Essayez une autre recherche."
+ : "Vos clients apparaîtront ici dès qu'une facture sera importée ou saisie."
+ }
+ />
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function SkeletonRows() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, idx) => (
+
+
+
+
+
+
+ ))}
);
}
diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx
new file mode 100644
index 0000000..3077dfc
--- /dev/null
+++ b/apps/web/src/routes/_app/clients_.$id.tsx
@@ -0,0 +1,316 @@
+import { useState, useEffect } from "react";
+import {
+ createFileRoute,
+ Link,
+} from "@tanstack/react-router";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Mail,
+ Phone,
+ MapPin,
+ AlertCircle,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import type { Client } from "@rubis/shared";
+import { api } from "@/lib/api";
+import { queryKeys } from "@/lib/queryKeys";
+import {
+ formatEuros,
+ formatDate,
+ formatRelativeDate,
+} from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+import { Card } from "@/components/ui/Card";
+import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Textarea } from "@/components/ui/Textarea";
+import { StatusBadge } from "@/components/ui/StatusBadge";
+import type { InvoiceListItem } from "@/components/factures/InvoiceTable";
+import type { ClientWithStats } from "@/components/clients/ClientTable";
+
+type ClientDetail = ClientWithStats & {
+ invoices: InvoiceListItem[];
+};
+
+export const Route = createFileRoute("/_app/clients_/$id")({
+ component: ClientDetailPage,
+ loader: ({ context, params }) => {
+ void context.queryClient.prefetchQuery({
+ queryKey: queryKeys.clients.detail(params.id),
+ queryFn: () =>
+ api.get(`/api/v1/clients/${params.id}`),
+ });
+ },
+});
+
+function ClientDetailPage() {
+ const { id } = Route.useParams();
+ const queryClient = useQueryClient();
+
+ const { data: client, isPending, isError } = useQuery({
+ queryKey: queryKeys.clients.detail(id),
+ queryFn: () => api.get(`/api/v1/clients/${id}`),
+ });
+
+ // Notes : édition locale + sauvegarde sur blur. Garde le draft local pour
+ // ne pas refetch écraser ce que l'user est en train de taper.
+ const [notesDraft, setNotesDraft] = useState("");
+ useEffect(() => {
+ if (client?.notes != null) setNotesDraft(client.notes);
+ else setNotesDraft("");
+ }, [client?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const updateNotesMutation = useMutation({
+ mutationFn: (notes: string) =>
+ api.patch(`/api/v1/clients/${id}`, { notes }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.clients.detail(id) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.clients.all() });
+ },
+ onError: () => {
+ toast.error("Notes non sauvegardées. Réessayez.");
+ },
+ });
+
+ if (isError) {
+ return (
+