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:
@@ -4,12 +4,14 @@ import { Header } from '@/components/layout/Header'
|
||||
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
||||
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
||||
import { CreationProgress } from '@/components/creation/CreationProgress'
|
||||
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||
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() {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
||||
@@ -19,11 +21,19 @@ export default function CreationPage() {
|
||||
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const handleSelectTab = (tab: string) => {
|
||||
setSelectedTab(tab)
|
||||
setStep('analyse')
|
||||
}
|
||||
|
||||
const analyse = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
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()
|
||||
if (data.error) throw new Error(data.error)
|
||||
setByTab(data.byTab)
|
||||
@@ -78,6 +88,15 @@ export default function CreationPage() {
|
||||
]
|
||||
const currentIdx = STEPS.findIndex(s => s.id === step)
|
||||
|
||||
const reset = () => {
|
||||
setStep('famille')
|
||||
setSelectedTab(null)
|
||||
setEvents([])
|
||||
setDone(false)
|
||||
setByTab({})
|
||||
setTotal(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Création produits" />
|
||||
@@ -93,13 +112,26 @@ export default function CreationPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* É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>}
|
||||
|
||||
{step === 'analyse' && (
|
||||
<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">
|
||||
Lit tous les onglets du Google Sheets et détecte les lignes où <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> où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
||||
</p>
|
||||
<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">
|
||||
@@ -152,7 +184,7 @@ export default function CreationPage() {
|
||||
<div className="space-y-4">
|
||||
<CreationProgress events={events} 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">
|
||||
Nouvelle session
|
||||
</button>
|
||||
|
||||
@@ -5,12 +5,14 @@ import { SyncStepper } from '@/components/sync/SyncStepper'
|
||||
import { DiffTable } from '@/components/sync/DiffTable'
|
||||
import { SyncProgress } from '@/components/sync/SyncProgress'
|
||||
import { SyncHistory } from '@/components/sync/SyncHistory'
|
||||
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||
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() {
|
||||
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 [error, setError] = useState<string | 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
|
||||
} | null>(null)
|
||||
|
||||
// Étape 0 → 1 : sélection famille
|
||||
const handleSelectTab = useCallback((tab: string) => {
|
||||
setSelectedTab(tab)
|
||||
setStep(1)
|
||||
}, [])
|
||||
|
||||
// Étape 1 → 2 : lancer le preview
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
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()
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
setDiff(data)
|
||||
@@ -39,7 +48,7 @@ export default function SyncPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [selectedTab])
|
||||
|
||||
// Étape 3 → 4 : lancer la sync en streaming
|
||||
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">
|
||||
{/* 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 */}
|
||||
{step === 1 && (
|
||||
<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">
|
||||
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l'aperçu des changements.
|
||||
Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
|
||||
</p>
|
||||
{error && (
|
||||
<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 && (
|
||||
<div className="flex gap-3 justify-end">
|
||||
<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"
|
||||
>
|
||||
↺ Nouvelle sync
|
||||
|
||||
@@ -5,14 +5,14 @@ import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
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)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||
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)
|
||||
const pending = await fetchPendingProducts(alreadyCreated, tab)
|
||||
const byTab: Record<string, PendingProduct[]> = {}
|
||||
const specificColumns = new Set<string>()
|
||||
for (const p of pending) {
|
||||
|
||||
@@ -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,11 +3,11 @@ import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
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 {
|
||||
const [sheetProducts, shopifyProducts] = await Promise.all([
|
||||
readSheetProducts(),
|
||||
readSheetProducts(tab),
|
||||
fetchAllShopifyProducts(),
|
||||
])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -82,19 +82,25 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
|
||||
|
||||
export async function fetchPendingProducts(
|
||||
alreadyCreatedRows: Set<string>,
|
||||
tabFilter?: string,
|
||||
): Promise<PendingProduct[]> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
||||
|
||||
let tabs: string[]
|
||||
if (tabFilter) {
|
||||
tabs = [tabFilter]
|
||||
} else {
|
||||
const metaRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||
)
|
||||
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||
const meta = await metaRes.json()
|
||||
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||
s => s.properties.title,
|
||||
)
|
||||
}
|
||||
|
||||
const all: PendingProduct[] = []
|
||||
for (const tab of tabs) {
|
||||
|
||||
@@ -52,16 +52,16 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
||||
* Sinon, lit tous les onglets du fichier et combine les produits.
|
||||
* 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 sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
const singleTab = process.env.GOOGLE_SHEETS_TAB
|
||||
const envTab = process.env.GOOGLE_SHEETS_TAB
|
||||
|
||||
if (!apiKey || !sheetId) {
|
||||
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(
|
||||
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
|
||||
|
||||
Reference in New Issue
Block a user