GenerateResponse now includes calendar field from backend. OutputReview merges CalendarSlot into PostResult via slot_index, enabling BadgePN, BadgeSchwartz rendering and Retry button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
/**
|
|
* Pagina Output Review.
|
|
*
|
|
* Carica i risultati di un job completato e mostra:
|
|
* - Griglia di PostCard con badge PN e Schwartz
|
|
* - SlideViewer inline espandibile con edit inline
|
|
* - Pulsante Download CSV (POST /api/export/{jobId}/csv con edits)
|
|
*
|
|
* Lo stato dei post (con le modifiche inline) è mantenuto localmente
|
|
* e inviato al backend via POST al momento del download CSV.
|
|
*/
|
|
|
|
import { Download, Loader2 } from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
import { useDownloadEditedCsv, useJobResults } from '../api/hooks'
|
|
import { PostCard } from '../components/PostCard'
|
|
import type { PostResult } from '../types'
|
|
|
|
export function OutputReview() {
|
|
const { jobId } = useParams<{ jobId: string }>()
|
|
const { data: jobData, isLoading, error } = useJobResults(jobId ?? null)
|
|
const downloadMutation = useDownloadEditedCsv()
|
|
|
|
// Stato locale dei post — viene aggiornato da edit inline e rigenerazione
|
|
const [localResults, setLocalResults] = useState<PostResult[]>([])
|
|
|
|
// Inizializza i risultati e merge con CalendarSlot dal calendario
|
|
useEffect(() => {
|
|
if (jobData?.results) {
|
|
const slots = jobData.calendar?.slots
|
|
const merged = jobData.results.map((r) => ({
|
|
...r,
|
|
slot: slots?.find((s) => s.indice === r.slot_index) ?? r.slot,
|
|
}))
|
|
setLocalResults(merged)
|
|
}
|
|
}, [jobData])
|
|
|
|
function handleEdit(updated: PostResult) {
|
|
setLocalResults((prev) =>
|
|
prev.map((r) => (r.slot_index === updated.slot_index ? updated : r))
|
|
)
|
|
}
|
|
|
|
function handleRegenerated(updated: PostResult) {
|
|
setLocalResults((prev) =>
|
|
prev.map((r) => (r.slot_index === updated.slot_index ? updated : r))
|
|
)
|
|
}
|
|
|
|
async function handleDownloadCsv() {
|
|
if (!jobId || !jobData) return
|
|
await downloadMutation.mutateAsync({
|
|
jobId,
|
|
results: localResults,
|
|
campagna: jobData.campagna,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Loading / Error states
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-64">
|
|
<Loader2 className="animate-spin text-stone-500" size={24} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !jobData) {
|
|
return (
|
|
<div className="max-w-2xl mx-auto px-6 py-10">
|
|
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
|
{error?.message ?? 'Risultati non trovati. Il job potrebbe non essere completato.'}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const successCount = localResults.filter((r) => r.status === 'success').length
|
|
const failedCount = localResults.filter((r) => r.status === 'failed').length
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI principale
|
|
// ---------------------------------------------------------------------------
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto px-6 py-10 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-stone-100">Output Review</h1>
|
|
<p className="mt-1 text-stone-400 text-sm">{jobData.campagna}</p>
|
|
<div className="flex items-center gap-4 mt-2">
|
|
<span className="text-xs text-emerald-400">{successCount} generati</span>
|
|
{failedCount > 0 && (
|
|
<span className="text-xs text-red-400">{failedCount} falliti</span>
|
|
)}
|
|
<span className="text-xs text-stone-600">job: {jobId}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Download CSV */}
|
|
<button
|
|
onClick={handleDownloadCsv}
|
|
disabled={downloadMutation.isPending || successCount === 0}
|
|
className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{downloadMutation.isPending ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Download size={14} />
|
|
)}
|
|
{downloadMutation.isPending ? 'Download...' : 'Download CSV'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Errore download */}
|
|
{downloadMutation.error && (
|
|
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
|
Errore download: {downloadMutation.error.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info edit inline */}
|
|
<div className="px-4 py-2.5 rounded-lg bg-stone-800/50 border border-stone-700 text-xs text-stone-500">
|
|
Clicca su una card per espandere le slide. I campi di testo sono editabili inline — le modifiche saranno incluse nel CSV scaricato.
|
|
</div>
|
|
|
|
{/* Griglia post */}
|
|
<div className="space-y-3">
|
|
{localResults.length === 0 ? (
|
|
<p className="text-sm text-stone-500 text-center py-8">
|
|
Nessun risultato disponibile.
|
|
</p>
|
|
) : (
|
|
localResults.map((result) => (
|
|
<PostCard
|
|
key={result.slot_index}
|
|
result={result}
|
|
obiettivoCampagna={jobData.campagna}
|
|
brandName={null}
|
|
tono={null}
|
|
onRegenerated={handleRegenerated}
|
|
onEdit={handleEdit}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Download CSV footer */}
|
|
{successCount > 0 && (
|
|
<div className="flex justify-center pt-4">
|
|
<button
|
|
onClick={handleDownloadCsv}
|
|
disabled={downloadMutation.isPending}
|
|
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 transition-colors"
|
|
>
|
|
{downloadMutation.isPending ? (
|
|
<Loader2 size={15} className="animate-spin" />
|
|
) : (
|
|
<Download size={15} />
|
|
)}
|
|
{downloadMutation.isPending ? 'Download CSV...' : `Scarica CSV (${successCount} post)`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|