feat: détection et suppression des doublons produits Shopify
- deleteShopifyProduct() dans shopifySync (DELETE REST API) - GET /api/products/duplicates : groupe par ref/SKU, trie par ID (ancien en premier) - DELETE /api/products/[id] : suppression unitaire avec auth - Page /produits/doublons : liste des groupes, radio pour choisir lequel garder, confirmation avant suppression, état par groupe (idle/confirm/deleting/done) - Lien 'Vérifier les doublons' dans l'en-tête de la page Produits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
|
||||
|
||||
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
|
||||
|
||||
interface GroupState {
|
||||
group: DuplicateGroup
|
||||
keepId: string
|
||||
deleteState: DeleteState
|
||||
deleted: string[] // IDs supprimés
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export default function DoublonsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [groupStates, setGroupStates] = useState<GroupState[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products/duplicates')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setGroupStates(
|
||||
(data.groups as DuplicateGroup[]).map((group) => ({
|
||||
group,
|
||||
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
|
||||
deleteState: 'idle',
|
||||
deleted: [],
|
||||
error: null,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const setKeep = useCallback((groupIdx: number, id: string) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const askConfirm = useCallback((groupIdx: number) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const cancelConfirm = useCallback((groupIdx: number) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const deleteGroup = useCallback(async (groupIdx: number) => {
|
||||
const gs = groupStates[groupIdx]
|
||||
const toDelete = gs.group.products
|
||||
.map((p) => p.shopifyId)
|
||||
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
|
||||
|
||||
setGroupStates((prev) =>
|
||||
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
|
||||
)
|
||||
|
||||
const newDeleted = [...gs.deleted]
|
||||
let lastError: string | null = null
|
||||
|
||||
for (const id of toDelete) {
|
||||
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
newDeleted.push(id)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
lastError = data.error ?? `Erreur HTTP ${res.status}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setGroupStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === groupIdx
|
||||
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
|
||||
: s
|
||||
)
|
||||
)
|
||||
}, [groupStates])
|
||||
|
||||
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="🔍 Doublons produits"
|
||||
actions={
|
||||
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
|
||||
← Retour au catalogue
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
||||
<span className="animate-spin">⏳</span> Analyse du catalogue Shopify…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && groupStates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-4xl mb-3">✅</p>
|
||||
<p className="text-white font-semibold">Aucun doublon détecté</p>
|
||||
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groupStates.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
|
||||
{remaining < groupStates.length && (
|
||||
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{groupStates.map((gs, groupIdx) => {
|
||||
const { group, keepId, deleteState, deleted, error: groupError } = gs
|
||||
const isDone = deleteState === 'done'
|
||||
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.ref}
|
||||
className={`border rounded-xl overflow-hidden transition-all ${
|
||||
isDone
|
||||
? 'border-green-700/50 bg-green-950/20 opacity-60'
|
||||
: 'border-slate-700 bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{/* En-tête groupe */}
|
||||
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
|
||||
<span className="ml-3 text-xs text-slate-500">
|
||||
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
|
||||
</span>
|
||||
</div>
|
||||
{!isDone && deleteState === 'idle' && (
|
||||
<button
|
||||
onClick={() => askConfirm(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
|
||||
>
|
||||
Supprimer les doublons
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liste des produits */}
|
||||
{!isDone && (
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{activeProducts.map((product) => {
|
||||
const isKeep = product.shopifyId === keepId
|
||||
return (
|
||||
<div
|
||||
key={product.shopifyId}
|
||||
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`keep-${groupIdx}`}
|
||||
checked={isKeep}
|
||||
onChange={() => setKeep(groupIdx, product.shopifyId)}
|
||||
className="accent-green-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{product.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
ID {product.shopifyId} ·{' '}
|
||||
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
|
||||
{product.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isKeep && (
|
||||
<span className="text-xs text-green-400 font-medium">À garder</span>
|
||||
)}
|
||||
<a
|
||||
href={product.shopifyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Shopify ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone de confirmation */}
|
||||
{deleteState === 'confirm' && (
|
||||
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-red-300">
|
||||
Supprimer définitivement{' '}
|
||||
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
|
||||
de Shopify. Cette action est irréversible.
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => cancelConfirm(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteGroup(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
|
||||
>
|
||||
Confirmer la suppression
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteState === 'deleting' && (
|
||||
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
|
||||
<span className="animate-spin">⏳</span> Suppression en cours…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupError && (
|
||||
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
|
||||
Erreur : {groupError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
||||
import { ProductTable } from '@/components/products/ProductTable'
|
||||
@@ -35,7 +36,14 @@ export default function ProduitsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Produits" />
|
||||
<Header
|
||||
title="Produits"
|
||||
actions={
|
||||
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
|
||||
🔍 Vérifier les doublons
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<ProductFiltersBar
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { deleteShopifyProduct } from '@/lib/shopifySync'
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
await deleteShopifyProduct(params.id)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import type { ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
ref: string
|
||||
products: Array<{
|
||||
shopifyId: string
|
||||
title: string
|
||||
status: string
|
||||
handle: string
|
||||
shopifyUrl: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||
const all = await fetchAllShopifyProducts()
|
||||
|
||||
// Grouper par ref (SKU)
|
||||
const byRef = new Map<string, ShopifyProductFull[]>()
|
||||
for (const p of all) {
|
||||
const key = p.ref.trim().toLowerCase()
|
||||
if (!byRef.has(key)) byRef.set(key, [])
|
||||
byRef.get(key)!.push(p)
|
||||
}
|
||||
|
||||
const groups: DuplicateGroup[] = []
|
||||
for (const products of Array.from(byRef.values())) {
|
||||
if (products.length < 2) continue
|
||||
// Trier : le plus ancien (ID numérique le plus petit) en premier
|
||||
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
|
||||
groups.push({
|
||||
ref: products[0].ref,
|
||||
products: products.map((p: ShopifyProductFull) => ({
|
||||
shopifyId: p.shopifyId,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
handle: p.handle,
|
||||
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Trier les groupes par ref
|
||||
groups.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||
|
||||
return NextResponse.json({ groups, total: groups.length })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement un produit Shopify.
|
||||
*/
|
||||
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify deleteProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'ID de la première variante d'un produit Shopify.
|
||||
* Nécessaire pour mettre à jour le prix (update variant).
|
||||
|
||||
Reference in New Issue
Block a user