feat: implémenter le dashboard avec KPIs, dernière sync et actions rapides
- Nouvel endpoint /api/dashboard/stats : comptes produits Shopify (actifs/brouillons/sans image) + dernière sync depuis Prisma - Nouvel endpoint /api/dashboard/pending : nombre de produits en attente depuis Google Sheets (lazy) - Page dashboard remplace le stub avec 4 KPIs, bloc dernière sync, et raccourcis vers les sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = (
|
||||
<div className={`rounded-xl border p-5 flex flex-col gap-1 ${accentClass} ${href ? 'hover:brightness-110 transition-all cursor-pointer' : ''}`}>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<p className="text-xs text-slate-400 mt-1">{label}</p>
|
||||
{loading ? (
|
||||
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-400">Erreur</p>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-white">{value ?? '—'}</p>
|
||||
)}
|
||||
{sub && <p className="text-xs text-slate-500">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
return href ? <Link href={href}>{content}</Link> : content
|
||||
}
|
||||
|
||||
function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl p-4 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl mt-0.5">{icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white group-hover:text-indigo-300 transition-colors">{label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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<DashboardStats | null>(null)
|
||||
const [statsError, setStatsError] = useState(false)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
|
||||
const [pendingCount, setPendingCount] = useState<number | null>(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 (
|
||||
<>
|
||||
<Header title="📊 Dashboard" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard — à implémenter (Plan 4)</p></div>
|
||||
|
||||
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
|
||||
|
||||
{/* ── KPIs ─────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d'ensemble</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon="📦"
|
||||
label="Produits actifs"
|
||||
value={stats?.products.active ?? null}
|
||||
sub={stats ? `${stats.products.draft} en brouillon` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent="green"
|
||||
href="/produits"
|
||||
/>
|
||||
<StatCard
|
||||
icon="🖼️"
|
||||
label="Sans image"
|
||||
value={stats?.products.withoutImages ?? null}
|
||||
sub={stats ? `sur ${stats.products.total} produits` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent={stats && stats.products.withoutImages > 0 ? 'amber' : 'green'}
|
||||
href="/images"
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔄"
|
||||
label="Dernière sync"
|
||||
value={lastSync ? formatDate(lastSync.createdAt) : 'Aucune'}
|
||||
sub={lastSync ? `par ${lastSync.userName}` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent="indigo"
|
||||
href="/sync"
|
||||
/>
|
||||
|
||||
{/* Pending — chargement lazy */}
|
||||
<div
|
||||
className="rounded-xl border border-indigo-500/30 bg-indigo-500/5 p-5 flex flex-col gap-1 cursor-pointer hover:brightness-110 transition-all"
|
||||
onClick={pendingCount === null && !pendingLoading ? loadPending : undefined}
|
||||
>
|
||||
<span className="text-xl">✨</span>
|
||||
<p className="text-xs text-slate-400 mt-1">En attente de création</p>
|
||||
{pendingLoading ? (
|
||||
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||
) : pendingError ? (
|
||||
<p className="text-sm text-red-400">Erreur — réessayer</p>
|
||||
) : pendingCount !== null ? (
|
||||
<p className="text-2xl font-bold text-white">{pendingCount}</p>
|
||||
) : (
|
||||
<button className="text-xs text-indigo-400 hover:text-indigo-300 text-left mt-1">
|
||||
Calculer →
|
||||
</button>
|
||||
)}
|
||||
{pendingCount !== null && (
|
||||
<p className="text-xs text-slate-500">depuis Google Sheets</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Dernière sync détail ──────────────────────────────── */}
|
||||
{!statsLoading && lastSync && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Dernière synchronisation</h2>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{formatDate(lastSync.createdAt)}</p>
|
||||
<p className="text-xs text-slate-400">par {lastSync.userName}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full border ${
|
||||
lastSync.status === 'success'
|
||||
? 'bg-green-900/30 border-green-700 text-green-400'
|
||||
: 'bg-red-900/30 border-red-700 text-red-400'
|
||||
}`}>
|
||||
{lastSync.status === 'success' ? '✅ Succès' : '❌ Erreur'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link href="/sync" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Nouvelle sync →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Actions rapides ───────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Actions rapides</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<ActionCard
|
||||
href="/images"
|
||||
icon="🖼️"
|
||||
label="Uploader des images"
|
||||
description="Convertir HEIC, détourer et envoyer sur Shopify"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/sync"
|
||||
icon="🔄"
|
||||
label="Synchroniser le catalogue"
|
||||
description="Comparer Google Sheets avec Shopify et appliquer les changements"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/creation"
|
||||
icon="✨"
|
||||
label="Créer des produits"
|
||||
description="Ajouter les produits en attente depuis Google Sheets"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/produits"
|
||||
icon="📦"
|
||||
label="Voir le catalogue"
|
||||
description="Parcourir et filtrer tous les produits Shopify"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<LightProduct[]> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user