feat: sélecteur de famille (onglet Sheets) en sync et création

- GET /api/sheets/tabs : liste les onglets Google Sheets disponibles
- readSheetProducts(tab?) et fetchPendingProducts(set, tab?) acceptent un filtre
- /api/sync/preview?tab=xxx et /api/creation/preview?tab=xxx filtrent sur un onglet
- Composant FamilySelector : grille de boutons chargeant les onglets dynamiquement
- Page Sync : étape 0 de sélection famille avant l'analyse
- Page Création : étape famille avant analyse, badge de famille sélectionnée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:31:19 +02:00
parent 1b07359415
commit 23aa78d3da
8 changed files with 180 additions and 30 deletions
+38 -6
View File
@@ -4,12 +4,14 @@ import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable' import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver' import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
import { CreationProgress } from '@/components/creation/CreationProgress' import { CreationProgress } from '@/components/creation/CreationProgress'
import { FamilySelector } from '@/components/common/FamilySelector'
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation' import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
type Step = 'analyse' | 'metafields' | 'confirmation' | 'execution' type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
export default function CreationPage() { export default function CreationPage() {
const [step, setStep] = useState<Step>('analyse') const [step, setStep] = useState<Step>('famille')
const [selectedTab, setSelectedTab] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({}) const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
@@ -19,11 +21,19 @@ export default function CreationPage() {
const [events, setEvents] = useState<CreationStreamEvent[]>([]) const [events, setEvents] = useState<CreationStreamEvent[]>([])
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const handleSelectTab = (tab: string) => {
setSelectedTab(tab)
setStep('analyse')
}
const analyse = async () => { const analyse = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch('/api/creation/preview') const url = selectedTab
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
: '/api/creation/preview'
const res = await fetch(url)
const data = await res.json() const data = await res.json()
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
setByTab(data.byTab) setByTab(data.byTab)
@@ -78,6 +88,15 @@ export default function CreationPage() {
] ]
const currentIdx = STEPS.findIndex(s => s.id === step) const currentIdx = STEPS.findIndex(s => s.id === step)
const reset = () => {
setStep('famille')
setSelectedTab(null)
setEvents([])
setDone(false)
setByTab({})
setTotal(0)
}
return ( return (
<> <>
<Header title="Création produits" /> <Header title="Création produits" />
@@ -93,13 +112,26 @@ export default function CreationPage() {
))} ))}
</div> </div>
<div className="bg-slate-800 rounded-xl p-6"> {/* Étape 0 — Sélection famille */}
{step === 'famille' && (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
<FamilySelector onSelect={handleSelectTab} />
</div>
)}
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>} {error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
{step === 'analyse' && ( {step === 'analyse' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
<span className="text-indigo-400 text-sm">📂 Famille :</span>
<span className="text-white text-sm font-semibold">{selectedTab}</span>
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
</div>
<p className="text-gray-300 text-sm"> <p className="text-gray-300 text-sm">
Lit tous les onglets du Google Sheets et détecte les lignes <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>. Détecte les lignes de <strong className="text-white">{selectedTab}</strong> <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
</p> </p>
<button onClick={analyse} disabled={loading} <button onClick={analyse} disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"> className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
@@ -152,7 +184,7 @@ export default function CreationPage() {
<div className="space-y-4"> <div className="space-y-4">
<CreationProgress events={events} done={done} /> <CreationProgress events={events} done={done} />
{done && ( {done && (
<button onClick={() => { setStep('analyse'); setEvents([]); setDone(false); setByTab({}); setTotal(0) }} <button onClick={reset}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors"> className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Nouvelle session Nouvelle session
</button> </button>
+29 -7
View File
@@ -5,12 +5,14 @@ import { SyncStepper } from '@/components/sync/SyncStepper'
import { DiffTable } from '@/components/sync/DiffTable' import { DiffTable } from '@/components/sync/DiffTable'
import { SyncProgress } from '@/components/sync/SyncProgress' import { SyncProgress } from '@/components/sync/SyncProgress'
import { SyncHistory } from '@/components/sync/SyncHistory' import { SyncHistory } from '@/components/sync/SyncHistory'
import { FamilySelector } from '@/components/common/FamilySelector'
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync' import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
type Step = 1 | 2 | 3 | 4 type Step = 0 | 1 | 2 | 3 | 4
export default function SyncPage() { export default function SyncPage() {
const [step, setStep] = useState<Step>(1) const [step, setStep] = useState<Step>(0)
const [selectedTab, setSelectedTab] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [diff, setDiff] = useState<DiffResult | null>(null) const [diff, setDiff] = useState<DiffResult | null>(null)
@@ -24,12 +26,19 @@ export default function SyncPage() {
created: number; updated: number; deactivated: number; errors: number; status: string created: number; updated: number; deactivated: number; errors: number; status: string
} | null>(null) } | null>(null)
// Étape 0 → 1 : sélection famille
const handleSelectTab = useCallback((tab: string) => {
setSelectedTab(tab)
setStep(1)
}, [])
// Étape 1 → 2 : lancer le preview // Étape 1 → 2 : lancer le preview
const handleAnalyze = useCallback(async () => { const handleAnalyze = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch('/api/sync/preview') const url = selectedTab ? `/api/sync/preview?tab=${encodeURIComponent(selectedTab)}` : '/api/sync/preview'
const res = await fetch(url)
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
setDiff(data) setDiff(data)
@@ -39,7 +48,7 @@ export default function SyncPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [selectedTab])
// Étape 3 → 4 : lancer la sync en streaming // Étape 3 → 4 : lancer la sync en streaming
const handleExecute = useCallback(async () => { const handleExecute = useCallback(async () => {
@@ -114,13 +123,26 @@ export default function SyncPage() {
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto"> <div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{/* Stepper */} {/* Stepper */}
<SyncStepper currentStep={step} /> <SyncStepper currentStep={step > 0 ? step : 1} />
{/* Étape 0 — Sélection famille */}
{step === 0 && (
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
<FamilySelector onSelect={handleSelectTab} />
</div>
)}
{/* Étape 1 — Analyser */} {/* Étape 1 — Analyser */}
{step === 1 && ( {step === 1 && (
<div className="flex flex-col items-center gap-4 py-8"> <div className="flex flex-col items-center gap-4 py-8">
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-4 py-2">
<span className="text-indigo-400 text-sm">📂 Famille :</span>
<span className="text-white text-sm font-semibold">{selectedTab}</span>
<button onClick={() => { setStep(0); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
</div>
<p className="text-slate-400 text-sm text-center max-w-md"> <p className="text-slate-400 text-sm text-center max-w-md">
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l&apos;aperçu des changements. Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
</p> </p>
{error && ( {error && (
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md"> <div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
@@ -196,7 +218,7 @@ export default function SyncPage() {
{summary && ( {summary && (
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button <button
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }} onClick={() => { setStep(0); setSelectedTab(null); setDiff(null); setSummary(null); setLog([]) }}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg" className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
> >
Nouvelle sync Nouvelle sync
+3 -3
View File
@@ -5,14 +5,14 @@ import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import type { PendingProduct } from '@/types/creation' import type { PendingProduct } from '@/types/creation'
// eslint-disable-next-line @typescript-eslint/no-unused-vars export async function GET(req: NextRequest) {
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }) if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
try { try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } }) const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`)) const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated) const pending = await fetchPendingProducts(alreadyCreated, tab)
const byTab: Record<string, PendingProduct[]> = {} const byTab: Record<string, PendingProduct[]> = {}
const specificColumns = new Set<string>() const specificColumns = new Set<string>()
for (const p of pending) { for (const p of pending) {
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) {
return NextResponse.json({ error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
}
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error: ${res.status}${err}`)
}
const data = await res.json()
const tabs: string[] = (data.sheets as Array<{ properties: { title: string } }>).map(
(s) => s.properties.title,
)
return NextResponse.json({ tabs })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+3 -3
View File
@@ -3,11 +3,11 @@ import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync' import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff' import { computeDiff } from '@/lib/syncDiff'
// eslint-disable-next-line @typescript-eslint/no-unused-vars export async function GET(req: NextRequest) {
export async function GET(_req: NextRequest) { const tab = new URL(req.url).searchParams.get('tab') ?? undefined
try { try {
const [sheetProducts, shopifyProducts] = await Promise.all([ const [sheetProducts, shopifyProducts] = await Promise.all([
readSheetProducts(), readSheetProducts(tab),
fetchAllShopifyProducts(), fetchAllShopifyProducts(),
]) ])
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
interface Props {
onSelect: (tab: string) => void
}
export function FamilySelector({ onSelect }: Props) {
const [tabs, setTabs] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/sheets/tabs')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setTabs(data.tabs)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
<span className="animate-spin"></span> Chargement des familles
</div>
)
}
if (error) {
return (
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
Impossible de charger les familles : {error}
</div>
)
}
return (
<div className="space-y-3">
<p className="text-slate-300 text-sm">
Choisissez une famille (onglet Google Sheets) pour travailler uniquement sur celle-ci.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => onSelect(tab)}
className="flex items-center gap-2 px-4 py-3 bg-slate-700 hover:bg-indigo-700 border border-slate-600 hover:border-indigo-500 rounded-xl text-sm text-slate-200 hover:text-white font-medium transition-all text-left group"
>
<span className="text-base group-hover:scale-110 transition-transform">📂</span>
<span className="truncate">{tab}</span>
</button>
))}
</div>
</div>
)
}
+14 -8
View File
@@ -82,19 +82,25 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
export async function fetchPendingProducts( export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>, alreadyCreatedRows: Set<string>,
tabFilter?: string,
): Promise<PendingProduct[]> { ): Promise<PendingProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis') if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
const metaRes = await fetch( let tabs: string[]
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`, if (tabFilter) {
) tabs = [tabFilter]
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`) } else {
const meta = await metaRes.json() const metaRes = await fetch(
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map( `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
s => s.properties.title, )
) if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
}
const all: PendingProduct[] = [] const all: PendingProduct[] = []
for (const tab of tabs) { for (const tab of tabs) {
+3 -3
View File
@@ -52,16 +52,16 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
* Sinon, lit tous les onglets du fichier et combine les produits. * Sinon, lit tous les onglets du fichier et combine les produits.
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien"). * Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
*/ */
export async function readSheetProducts(): Promise<SyncProduct[]> { export async function readSheetProducts(tab?: string): Promise<SyncProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID const sheetId = process.env.GOOGLE_SHEETS_ID
const singleTab = process.env.GOOGLE_SHEETS_TAB const envTab = process.env.GOOGLE_SHEETS_TAB
if (!apiKey || !sheetId) { if (!apiKey || !sheetId) {
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises') throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
} }
const sheetNames = singleTab ? [singleTab] : await listSheetTabs(sheetId, apiKey) const sheetNames = tab ? [tab] : envTab ? [envTab] : await listSheetTabs(sheetId, apiKey)
const results = await Promise.all( const results = await Promise.all(
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)), sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),