feat(plan5): composants PendingRowsTable + MetafieldResolver + CreationProgress

This commit is contained in:
2026-06-09 10:14:12 +02:00
parent 40d537efb6
commit 1ed8c055d7
3 changed files with 224 additions and 0 deletions
@@ -0,0 +1,53 @@
'use client'
import type { CreationStreamEvent } from '@/types/creation'
interface Props {
events: CreationStreamEvent[]
done: boolean
}
export function CreationProgress({ events, done }: Props) {
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
return (
<div className="space-y-4">
{lastProgress && !done && (
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>{lastProgress.step}</span>
<span>{lastProgress.current}/{lastProgress.total}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-indigo-600 h-2 rounded-full transition-all"
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} {lastProgress.title}</p>
</div>
)}
{errors.length > 0 && (
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
<span className="font-medium">{e.title}</span>
<span className="text-red-400"> {e.message}</span>
</div>
))}
</div>
)}
{doneEvent && (
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
</p>
<p className="text-sm text-gray-300 mt-1">
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
{doneEvent.summary.errors > 0 && `${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
</p>
</div>
)}
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage</p>}
</div>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
interface Props {
specificColumns: string[]
onResolved: (resolutions: MetafieldResolution[]) => void
}
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
const [unmatched, setUnmatched] = useState<string[]>([])
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
fetch(`/api/creation/metafields?columns=${params}`)
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
data.unmatched.forEach((col: string) => {
init[col] = { columnHeader: col, action: 'create', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
useEffect(() => {
if (!loading) onResolved(Object.values(resolutions))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolutions, loading])
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
}
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify</p>
if (error) return <p className="text-red-400 text-sm">{error}</p>
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
return (
<div className="space-y-4">
{Object.entries(matched).length > 0 && (
<div>
<h3 className="text-sm font-medium text-green-400 mb-2">
{Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
</h3>
<div className="space-y-1">
{Object.entries(matched).map(([col, res]) => (
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
<span className="text-gray-300 flex-1">{col}</span>
<span className="text-green-400 text-xs"> {res.namespace}.{res.key}</span>
</div>
))}
</div>
</div>
)}
{unmatched.length > 0 && (
<div>
<h3 className="text-sm font-medium text-yellow-400 mb-2">
{unmatched.length} colonne{unmatched.length > 1 ? 's' : ''} sans correspondance action requise
</h3>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className="p-3 bg-slate-700 rounded-lg space-y-2">
<p className="text-sm text-white font-medium">{col}</p>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${res.action === action ? 'bg-indigo-600 text-white' : 'bg-slate-600 text-gray-300 hover:bg-slate-500'}`}
>
{action === 'create' ? 'Créer nouveau' : action === 'map' ? 'Associer existant' : 'Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2">
<input value={res.key} onChange={e => updateResolution(col, { key: e.target.value })} placeholder="clé (ex: puissance)"
className="flex-1 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
<input value={res.namespace} onChange={e => updateResolution(col, { namespace: e.target.value })} placeholder="namespace"
className="w-24 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
</div>
)}
{res.action === 'map' && (
<select value={res.existingDefinitionId ?? ''}
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white">
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
'use client'
import type { PendingProduct } from '@/types/creation'
interface Props {
byTab: Record<string, PendingProduct[]>
total: number
}
export function PendingRowsTable({ byTab, total }: Props) {
if (total === 0) {
return (
<div className="text-center py-12 text-gray-400 text-sm">
Aucun produit en attente (Publié=1 et ID vide).
</div>
)
}
return (
<div className="space-y-6">
{Object.entries(byTab).map(([tab, products]) => (
<div key={tab}>
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
{tab} {products.length} produit{products.length > 1 ? 's' : ''}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-2 pr-4 font-medium">SKU</th>
<th className="pb-2 pr-4 font-medium">Titre</th>
<th className="pb-2 pr-4 font-medium">Prix</th>
<th className="pb-2 pr-4 font-medium">Stock</th>
<th className="pb-2 font-medium">Métachamps</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
<td className="py-2 pr-4 text-white">{p.title}</td>
<td className="py-2 pr-4 text-gray-300">{p.priceActual} </td>
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
<td className="py-2 text-gray-400 text-xs">
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}