feat(01-04): pagine GenerateCalendar, OutputReview, GenerateSingle complete
- GenerateCalendar.tsx: form con obiettivo+settimane+brand+tono+nicchie - Pulsante Genera disabilitato senza API key con banner link a Impostazioni - Async submit: mutation ritorna job_id, mostra ProgressIndicator - Auto-navigate a /risultati/:jobId quando job completato - OutputReview.tsx: carica job results via useJobResults(jobId) - Griglia PostCard responsive con conteggio success/failed - Stato locale per edit inline (aggiornato da PostCard.onEdit) - Download CSV via useDownloadEditedCsv (POST con edits) con due pulsanti (header + footer) - GenerateSingle.tsx: form con tipo PN, livello Schwartz, nicchia, formato narrativo - Topic opzionale (altrimenti generato dall'AI) - Anteprima risultato con PostCard+SlideViewer e download CSV singolo
This commit is contained in:
@@ -1,6 +1,168 @@
|
||||
/**
|
||||
* Pagina Output Review (stub — completata nel Task 2c)
|
||||
* 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() {
|
||||
return <div className="p-6 text-stone-400">Output Review — in costruzione</div>
|
||||
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 quando arrivano dal backend
|
||||
useEffect(() => {
|
||||
if (jobData?.results) {
|
||||
setLocalResults(jobData.results)
|
||||
}
|
||||
}, [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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user