feat(plan5): composants PendingRowsTable + MetafieldResolver + CreationProgress
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user