diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx
index 98a0bb9..12b78c1 100644
--- a/src/app/(dashboard)/page.tsx
+++ b/src/app/(dashboard)/page.tsx
@@ -1,9 +1,276 @@
+'use client'
+import { useEffect, useState, useCallback } from 'react'
+import Link from 'next/link'
import { Header } from '@/components/layout/Header'
+
+interface ProductStats {
+ active: number
+ draft: number
+ total: number
+ withoutImages: number
+}
+
+interface LastSync {
+ createdAt: string
+ userName: string
+ status: string
+ created: number
+ updated: number
+ deactivated: number
+ errors: number
+}
+
+interface DashboardStats {
+ products: ProductStats
+ lastSync: LastSync | null
+}
+
+function StatCard({
+ icon,
+ label,
+ value,
+ sub,
+ href,
+ loading,
+ error,
+ accent,
+}: {
+ icon: string
+ label: string
+ value: string | number | null
+ sub?: string
+ href?: string
+ loading?: boolean
+ error?: boolean
+ accent?: 'indigo' | 'amber' | 'green' | 'red'
+}) {
+ const accentClass = {
+ indigo: 'border-indigo-500/30 bg-indigo-500/5',
+ amber: 'border-amber-500/30 bg-amber-500/5',
+ green: 'border-green-500/30 bg-green-500/5',
+ red: 'border-red-500/30 bg-red-500/5',
+ }[accent ?? 'indigo']
+
+ const content = (
+
+
{icon}
+
{label}
+ {loading ? (
+
β
+ ) : error ? (
+
Erreur
+ ) : (
+
{value ?? 'β'}
+ )}
+ {sub &&
{sub}
}
+
+ )
+
+ return href ? {content} : content
+}
+
+function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
+ return (
+
+ {icon}
+
+
{label}
+
{description}
+
+
+ )
+}
+
+function formatDate(iso: string) {
+ return new Intl.DateTimeFormat('fr-FR', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ }).format(new Date(iso))
+}
+
export default function DashboardPage() {
+ const [stats, setStats] = useState(null)
+ const [statsError, setStatsError] = useState(false)
+ const [statsLoading, setStatsLoading] = useState(true)
+
+ const [pendingCount, setPendingCount] = useState(null)
+ const [pendingLoading, setPendingLoading] = useState(false)
+ const [pendingError, setPendingError] = useState(false)
+
+ useEffect(() => {
+ fetch('/api/dashboard/stats')
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.error) throw new Error(data.error)
+ setStats(data)
+ })
+ .catch(() => setStatsError(true))
+ .finally(() => setStatsLoading(false))
+ }, [])
+
+ const loadPending = useCallback(async () => {
+ setPendingLoading(true)
+ setPendingError(false)
+ try {
+ const r = await fetch('/api/dashboard/pending')
+ const data = await r.json()
+ if (data.error) throw new Error(data.error)
+ setPendingCount(data.count)
+ } catch {
+ setPendingError(true)
+ } finally {
+ setPendingLoading(false)
+ }
+ }, [])
+
+ const lastSync = stats?.lastSync
+ const syncStatusColor =
+ lastSync?.status === 'success' ? 'text-green-400' :
+ lastSync?.status === 'error' ? 'text-red-400' : 'text-slate-400'
+
return (
<>
- Dashboard β Γ implΓ©menter (Plan 4)
+
+
+
+ {/* ββ KPIs βββββββββββββββββββββββββββββββββββββββββββββββ */}
+
+ Vue d'ensemble
+
+
+
0 ? 'amber' : 'green'}
+ href="/images"
+ />
+
+
+ {/* Pending β chargement lazy */}
+
+
β¨
+
En attente de crΓ©ation
+ {pendingLoading ? (
+
β
+ ) : pendingError ? (
+
Erreur β rΓ©essayer
+ ) : pendingCount !== null ? (
+
{pendingCount}
+ ) : (
+
+ )}
+ {pendingCount !== null && (
+
depuis Google Sheets
+ )}
+
+
+
+
+ {/* ββ DerniΓ¨re sync dΓ©tail ββββββββββββββββββββββββββββββββ */}
+ {!statsLoading && lastSync && (
+
+ Dernière synchronisation
+
+
+
+
{formatDate(lastSync.createdAt)}
+
par {lastSync.userName}
+
+
+ {lastSync.status === 'success' ? 'β
SuccΓ¨s' : 'β Erreur'}
+
+
+
+
+ {[
+ { label: 'CrΓ©ations', value: lastSync.created, color: 'text-green-400' },
+ { label: 'Mises Γ jour', value: lastSync.updated, color: 'text-blue-400' },
+ { label: 'DΓ©sactivΓ©s', value: lastSync.deactivated, color: 'text-amber-400' },
+ { label: 'Erreurs', value: lastSync.errors, color: 'text-red-400' },
+ ].map(({ label, value, color }) => (
+
+ ))}
+
+
+
+
+ Nouvelle sync β
+
+
+
+
+ )}
+
+ {/* ββ Actions rapides βββββββββββββββββββββββββββββββββββββ */}
+
+
+
>
)
}
diff --git a/src/app/api/dashboard/pending/route.ts b/src/app/api/dashboard/pending/route.ts
new file mode 100644
index 0000000..0c26d8f
--- /dev/null
+++ b/src/app/api/dashboard/pending/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { fetchPendingProducts } from '@/lib/creationSheets'
+import { prisma } from '@/lib/prisma'
+
+export async function GET() {
+ const session = await getServerSession(authOptions)
+ if (!session) return NextResponse.json({ error: 'Non authentifiΓ©' }, { status: 401 })
+
+ try {
+ const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
+ const alreadyCreated = new Set(existing.map((e) => `${e.sheetTab}:${e.sheetRow}`))
+ const pending = await fetchPendingProducts(alreadyCreated)
+ return NextResponse.json({ count: pending.length })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Erreur inconnue'
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}
diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts
new file mode 100644
index 0000000..1f583a1
--- /dev/null
+++ b/src/app/api/dashboard/stats/route.ts
@@ -0,0 +1,88 @@
+import { NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+
+const API_VERSION = '2024-01'
+
+function getShopifyConfig() {
+ const domain = process.env.SHOPIFY_STORE_DOMAIN
+ const token = process.env.SHOPIFY_ADMIN_API_TOKEN
+ if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
+ return {
+ baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
+ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
+ }
+}
+
+function extractNextUrl(linkHeader: string | null): string | null {
+ if (!linkHeader) return null
+ const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
+ return match ? match[1] : null
+}
+
+interface LightProduct {
+ id: number
+ status: string
+ images: Array<{ id: number }>
+}
+
+async function fetchProductsLight(status: 'active' | 'draft'): Promise {
+ const { baseUrl, headers } = getShopifyConfig()
+ const all: LightProduct[] = []
+ let url: string | null =
+ `${baseUrl}/products.json?limit=250&fields=id,status,images&status=${status}`
+
+ while (url) {
+ const res = await fetch(url, { headers, cache: 'no-store' })
+ if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
+ const data = await res.json()
+ all.push(...(data.products as LightProduct[]))
+ url = extractNextUrl(res.headers.get('Link'))
+ }
+ return all
+}
+
+export async function GET() {
+ const session = await getServerSession(authOptions)
+ if (!session) return NextResponse.json({ error: 'Non authentifiΓ©' }, { status: 401 })
+
+ try {
+ const [activeProducts, draftProducts, lastSyncRun] = await Promise.all([
+ fetchProductsLight('active'),
+ fetchProductsLight('draft'),
+ prisma.syncRun.findFirst({
+ orderBy: { createdAt: 'desc' },
+ include: { user: { select: { name: true } } },
+ }),
+ ])
+
+ const allProducts = [...activeProducts, ...draftProducts]
+ const withoutImages = allProducts.filter((p) => p.images.length === 0).length
+
+ const lastSync = lastSyncRun
+ ? {
+ createdAt: lastSyncRun.createdAt.toISOString(),
+ userName: lastSyncRun.user.name,
+ status: lastSyncRun.status,
+ created: lastSyncRun.created,
+ updated: lastSyncRun.updated,
+ deactivated: lastSyncRun.deactivated,
+ errors: lastSyncRun.errors,
+ }
+ : null
+
+ return NextResponse.json({
+ products: {
+ active: activeProducts.length,
+ draft: draftProducts.length,
+ total: allProducts.length,
+ withoutImages,
+ },
+ lastSync,
+ })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Erreur inconnue'
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}