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 }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ +
+ + Nouvelle sync β†’ + +
+
+
+ )} + + {/* ── Actions rapides ───────────────────────────────────── */} +
+

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 }) + } +}