Files
gestion-materiaux-destock/src/components/creation/MetafieldResolver.tsx
T
C_Ma_For 114e20c9fb feat: persist metafield resolutions across sessions
Les associations colonne→métachamp sont sauvegardées dans data/metafield-resolutions.json
et rechargées automatiquement à la prochaine création de produits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:28:53 +02:00

220 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect, useRef } 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)
const [searches, setSearches] = useState<Record<string, string>>({})
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
Promise.all([
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
fetch('/api/creation/resolutions').then(r => r.json()),
])
.then(([data, cache]: [{ error?: string; definitions: MetafieldDefinition[]; matched: Record<string, MetafieldResolution>; unmatched: string[] }, Record<string, MetafieldResolution>]) => {
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) => {
// Utilise le cache persisté si disponible, sinon 'skip' par défaut
init[col] = cache[col] ?? { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError((e as Error).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 => {
const next = { ...prev, [col]: { ...prev[col], ...partial } }
// Sauvegarde en différé pour éviter un appel par frappe
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
fetch('/api/creation/resolutions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
}).catch(() => null)
}, 800)
return next
})
}
const filteredDefs = (col: string) => {
const q = (searches[col] ?? '').toLowerCase()
if (!q) return definitions
return definitions.filter(d =>
d.name.toLowerCase().includes(q) ||
d.key.toLowerCase().includes(q) ||
d.namespace.toLowerCase().includes(q)
)
}
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>
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
<p className="text-xs text-gray-400">
Ces colonnes n&apos;ont pas de métachamp correspondant dans Shopify.
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
</p>
</div>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
<div className="flex items-center justify-between">
<p className="text-sm text-white font-medium">{col}</p>
{res.action === 'create' && <span className="text-xs text-indigo-300"> Sera créé dans Shopify</span>}
{res.action === 'map' && <span className="text-xs text-blue-300"> Associé à un existant</span>}
{res.action === 'skip' && <span className="text-xs text-gray-500"> Ignoré</span>}
</div>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
type="button"
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
res.action === action
? action === 'create' ? 'bg-indigo-600 text-white'
: action === 'map' ? 'bg-blue-600 text-white'
: 'bg-slate-500 text-white'
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
}`}
>
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? '↔ Associer existant' : '✕ Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2 mt-1">
<div className="flex-1">
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
<input
type="text"
value={res.key}
onChange={e => updateResolution(col, { key: e.target.value })}
placeholder="ex: puissance"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="w-28">
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
<input
type="text"
value={res.namespace}
onChange={e => updateResolution(col, { namespace: e.target.value })}
placeholder="custom"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
)}
{res.action === 'map' && (
<div className="relative" ref={openDropdown === col ? dropdownRef : undefined}>
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
{res.existingDefinitionId ? (
<div className="flex items-center gap-2">
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
</span>
<button type="button" onClick={() => { updateResolution(col, { existingDefinitionId: undefined, key: normalizeMetafieldKey(col), namespace: 'custom' }); setSearches(p => ({ ...p, [col]: '' })); setOpenDropdown(col) }} className="text-xs text-gray-400 hover:text-white px-1"></button>
</div>
) : (
<div>
<input
type="text"
autoFocus
value={searches[col] ?? ''}
onChange={e => { setSearches(p => ({ ...p, [col]: e.target.value })); setOpenDropdown(col) }}
onFocus={() => setOpenDropdown(col)}
placeholder="Rechercher un métachamp…"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{openDropdown === col && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
{filteredDefs(col).length === 0 ? (
<p className="text-xs text-gray-400 px-3 py-2">Aucun résultat</p>
) : filteredDefs(col).map(d => (
<button
key={d.id}
type="button"
onMouseDown={() => { updateResolution(col, { existingDefinitionId: d.id, key: d.key, namespace: d.namespace, type: d.type }); setOpenDropdown(null) }}
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-slate-600 flex justify-between gap-2"
>
<span>{d.name}</span>
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Résumé des actions */}
<div className="mt-3 flex gap-4 text-xs text-gray-400">
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
</div>
</div>
)}
</div>
)
}