diff --git a/src/app/(dashboard)/produits/doublons/page.tsx b/src/app/(dashboard)/produits/doublons/page.tsx new file mode 100644 index 0000000..9fa8bbb --- /dev/null +++ b/src/app/(dashboard)/produits/doublons/page.tsx @@ -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(null) + const [groupStates, setGroupStates] = useState([]) + + 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 ( + <> +
+ ← Retour au catalogue + + } + /> + +
+ + {loading && ( +
+ Analyse du catalogue Shopify… +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && groupStates.length === 0 && ( +
+

+

Aucun doublon détecté

+

Tous les produits ont une référence unique.

+
+ )} + + {!loading && groupStates.length > 0 && ( + <> +
+

+ {groupStates.length} groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés + {remaining < groupStates.length && ( + · {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''} + )} +

+
+ +
+ {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 ( +
+ {/* En-tête groupe */} +
+
+ {group.ref} + + {isDone ? '✅ Résolu' : `${activeProducts.length} produits`} + +
+ {!isDone && deleteState === 'idle' && ( + + )} +
+ + {/* Liste des produits */} + {!isDone && ( +
+ {activeProducts.map((product) => { + const isKeep = product.shopifyId === keepId + return ( +
+ setKeep(groupIdx, product.shopifyId)} + className="accent-green-500 cursor-pointer" + /> +
+

{product.title}

+

+ ID {product.shopifyId} ·{' '} + + {product.status} + +

+
+
+ {isKeep && ( + À garder + )} + + Shopify ↗ + +
+
+ ) + })} +
+ )} + + {/* Zone de confirmation */} + {deleteState === 'confirm' && ( +
+

+ Supprimer définitivement{' '} + {activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}{' '} + de Shopify. Cette action est irréversible. +

+
+ + +
+
+ )} + + {deleteState === 'deleting' && ( +
+ Suppression en cours… +
+ )} + + {groupError && ( +
+ Erreur : {groupError} +
+ )} +
+ ) + })} +
+ + )} +
+ + ) +} diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx index d03781d..9a24909 100644 --- a/src/app/(dashboard)/produits/page.tsx +++ b/src/app/(dashboard)/produits/page.tsx @@ -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 ( <> -
+
+ 🔍 Vérifier les doublons + + } + />
+} + +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() + 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 }) + } +} diff --git a/src/lib/shopifySync.ts b/src/lib/shopifySync.ts index 815cb27..4f4b57f 100644 --- a/src/lib/shopifySync.ts +++ b/src/lib/shopifySync.ts @@ -164,6 +164,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise } } +/** + * Supprime définitivement un produit Shopify. + */ +export async function deleteShopifyProduct(shopifyId: string): Promise { + 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).