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,247 @@
|
|||||||
/**
|
/**
|
||||||
* Pagina Genera Calendario (stub — completata nel Task 2c)
|
* Pagina Genera Calendario.
|
||||||
|
*
|
||||||
|
* Flusso:
|
||||||
|
* 1. Utente compila form (obiettivo + settimane)
|
||||||
|
* 2. Submit chiama POST /api/generate/bulk → ritorna {job_id} (202 Accepted)
|
||||||
|
* 3. Mostra ProgressIndicator con polling ogni 2s su job_id
|
||||||
|
* 4. Quando il job completa → navigate a /risultati/:jobId
|
||||||
|
*
|
||||||
|
* Il pulsante Genera è disabilitato se API key non configurata.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useGenerateCalendar, useSettings, useSettingsStatus } from '../api/hooks'
|
||||||
|
import { ProgressIndicator } from '../components/ProgressIndicator'
|
||||||
|
import type { CalendarRequest } from '../types'
|
||||||
|
|
||||||
export function GenerateCalendar() {
|
export function GenerateCalendar() {
|
||||||
return <div className="p-6 text-stone-400">Genera Calendario — in costruzione</div>
|
const navigate = useNavigate()
|
||||||
|
const { data: status } = useSettingsStatus()
|
||||||
|
const { data: settings } = useSettings()
|
||||||
|
const generateMutation = useGenerateCalendar()
|
||||||
|
|
||||||
|
const apiKeyOk = status?.api_key_configured ?? false
|
||||||
|
|
||||||
|
// Stato del form
|
||||||
|
const [obiettivo, setObiettivo] = useState('')
|
||||||
|
const [settimane, setSettimane] = useState(2)
|
||||||
|
const [brandName, setBrandName] = useState('')
|
||||||
|
const [tono, setTono] = useState('')
|
||||||
|
const [customNicchie, setCustomNicchie] = useState(false)
|
||||||
|
|
||||||
|
// Job ID quando la generazione parte
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!apiKeyOk || !obiettivo.trim()) return
|
||||||
|
|
||||||
|
const req: CalendarRequest = {
|
||||||
|
obiettivo_campagna: obiettivo.trim(),
|
||||||
|
settimane,
|
||||||
|
frequenza_post: settings?.frequenza_post ?? 3,
|
||||||
|
nicchie: customNicchie ? settings?.nicchie_attive : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await generateMutation.mutateAsync(req)
|
||||||
|
setJobId(res.job_id)
|
||||||
|
} catch {
|
||||||
|
// Errore gestito da generateMutation.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComplete(completedJobId: string) {
|
||||||
|
navigate(`/risultati/${completedJobId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Se il job è stato avviato: mostra progress indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (jobId) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-10 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-stone-100">Generazione in corso</h1>
|
||||||
|
<p className="mt-1 text-stone-400 text-sm">
|
||||||
|
Claude sta generando i 13 caroselli Instagram. Ci vorranno alcuni minuti.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-stone-700 bg-stone-800 px-5 py-5">
|
||||||
|
<ProgressIndicator jobId={jobId} onComplete={handleComplete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-stone-600 text-center">
|
||||||
|
Non chiudere questa pagina. Job ID: <span className="font-mono">{jobId}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Form generazione
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-10 space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-stone-100">Genera Calendario</h1>
|
||||||
|
<p className="mt-1 text-stone-400 text-sm">
|
||||||
|
Genera 13 caroselli Instagram strategici basati sul framework Persuasion Nurturing e Schwartz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner API key mancante */}
|
||||||
|
{!apiKeyOk && (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||||
|
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-amber-300 font-medium">API key Claude non configurata</p>
|
||||||
|
<p className="text-stone-400 mt-0.5">
|
||||||
|
<Link to="/impostazioni" className="text-amber-400 underline underline-offset-2">
|
||||||
|
Configura la tua API key nelle Impostazioni
|
||||||
|
</Link>{' '}
|
||||||
|
per abilitare la generazione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
||||||
|
{/* Obiettivo campagna */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">
|
||||||
|
Obiettivo campagna <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={obiettivo}
|
||||||
|
onChange={(e) => setObiettivo(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
placeholder="Es. Acquisire nuovi clienti dentisti nel Nord Italia che vogliono modernizzare il loro studio"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-stone-600">
|
||||||
|
Descrivi il target e l'obiettivo principale (min 10 caratteri).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settimane */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Durata ciclo</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settimane}
|
||||||
|
onChange={(e) => setSettimane(Math.max(1, Math.min(12, parseInt(e.target.value) || 2)))}
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="w-20 px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-stone-400">settimane</span>
|
||||||
|
<span className="text-xs text-stone-600">
|
||||||
|
({Math.ceil(settimane * (settings?.frequenza_post ?? 3))} post stimati, genera 13)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand name (opzionale) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">
|
||||||
|
Brand / Studio{' '}
|
||||||
|
<span className="text-stone-600 font-normal">(opzionale — sovrascrive impostazioni)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={brandName}
|
||||||
|
onChange={(e) => setBrandName(e.target.value)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
placeholder={settings?.brand_name ?? 'Es. Studio Bianchi & Associati'}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tono (opzionale) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">
|
||||||
|
Tono di voce{' '}
|
||||||
|
<span className="text-stone-600 font-normal">(opzionale)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tono}
|
||||||
|
onChange={(e) => setTono(e.target.value)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
placeholder={settings?.tono ?? 'Es. diretto e concreto'}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nicchie */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={customNicchie}
|
||||||
|
onChange={(e) => setCustomNicchie(e.target.checked)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="rounded border-stone-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-stone-300">
|
||||||
|
Usa nicchie da Impostazioni
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{customNicchie && settings?.nicchie_attive && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pl-5">
|
||||||
|
{settings.nicchie_attive.map((n) => (
|
||||||
|
<span key={n} className="text-xs px-2 py-0.5 rounded-full bg-stone-700 text-stone-400 border border-stone-600">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!customNicchie && (
|
||||||
|
<p className="text-xs text-stone-600 pl-5">
|
||||||
|
Verranno usate le nicchie default: generico, dentisti, avvocati, ecommerce, local_business, agenzie.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errore */}
|
||||||
|
{generateMutation.error && (
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
||||||
|
{generateMutation.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!apiKeyOk || !obiettivo.trim() || generateMutation.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 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending && <Loader2 size={15} className="animate-spin" />}
|
||||||
|
{generateMutation.isPending ? 'Avvio generazione...' : 'Genera Calendario'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!apiKeyOk && (
|
||||||
|
<p className="mt-2 text-xs text-stone-600">
|
||||||
|
Pulsante disabilitato — API key non configurata.{' '}
|
||||||
|
<Link to="/impostazioni" className="text-amber-400">Vai alle Impostazioni</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,273 @@
|
|||||||
/**
|
/**
|
||||||
* Pagina Genera Singolo Post (stub — completata nel Task 2c)
|
* Pagina Genera Singolo Post.
|
||||||
|
*
|
||||||
|
* Form per generare un singolo carosello Instagram:
|
||||||
|
* - Topic / descrizione
|
||||||
|
* - Tipo contenuto PN
|
||||||
|
* - Livello Schwartz
|
||||||
|
* - Nicchia target
|
||||||
|
* - Formato narrativo
|
||||||
|
*
|
||||||
|
* Mostra il risultato con SlideViewer e pulsante download CSV singolo.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useDownloadEditedCsv, useGenerateSingle, useSettingsStatus } from '../api/hooks'
|
||||||
|
import { PostCard } from '../components/PostCard'
|
||||||
|
import type { CalendarSlot, FormatoNarrativo, LivelloSchwartz, PostResult, TipoContenuto } from '../types'
|
||||||
|
|
||||||
|
const TIPI_PN: { value: TipoContenuto; label: string }[] = [
|
||||||
|
{ value: 'valore', label: 'Valore' },
|
||||||
|
{ value: 'storytelling', label: 'Storytelling' },
|
||||||
|
{ value: 'news', label: 'News' },
|
||||||
|
{ value: 'riprova_sociale', label: 'Social Proof' },
|
||||||
|
{ value: 'coinvolgimento', label: 'Coinvolgimento' },
|
||||||
|
{ value: 'promozione', label: 'Promozione' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIVELLI_SCHWARTZ: { value: LivelloSchwartz; label: string }[] = [
|
||||||
|
{ value: 'L5', label: 'L5 — Inconsapevole del problema' },
|
||||||
|
{ value: 'L4', label: 'L4 — Consapevole del problema' },
|
||||||
|
{ value: 'L3', label: 'L3 — Consapevole della soluzione' },
|
||||||
|
{ value: 'L2', label: 'L2 — Consapevole del prodotto' },
|
||||||
|
{ value: 'L1', label: 'L1 — Pronto all\'acquisto' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FORMATI: { value: FormatoNarrativo; label: string }[] = [
|
||||||
|
{ value: 'PAS', label: 'PAS — Problema → Agitazione → Soluzione' },
|
||||||
|
{ value: 'AIDA', label: 'AIDA — Attenzione → Interesse → Desiderio → Azione' },
|
||||||
|
{ value: 'BAB', label: 'BAB — Before → After → Bridge' },
|
||||||
|
{ value: 'Listicle', label: 'Listicle — Lista numerata' },
|
||||||
|
{ value: 'Storytelling', label: 'Storytelling — Narrativa emotiva' },
|
||||||
|
{ value: 'Dato_Implicazione', label: 'Dato + Implicazione → Azione' },
|
||||||
|
{ value: 'Obiezione_Risposta', label: 'Obiezione → Confutazione → Soluzione' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const NICCHIE = ['generico', 'dentisti', 'avvocati', 'ecommerce', 'local_business', 'agenzie']
|
||||||
|
|
||||||
export function GenerateSingle() {
|
export function GenerateSingle() {
|
||||||
return <div className="p-6 text-stone-400">Genera Singolo Post — in costruzione</div>
|
const { data: status } = useSettingsStatus()
|
||||||
|
const generateMutation = useGenerateSingle()
|
||||||
|
const downloadMutation = useDownloadEditedCsv()
|
||||||
|
|
||||||
|
const apiKeyOk = status?.api_key_configured ?? false
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [topic, setTopic] = useState('')
|
||||||
|
const [obiettivo, setObiettivo] = useState('')
|
||||||
|
const [tipoPN, setTipoPN] = useState<TipoContenuto>('valore')
|
||||||
|
const [livello, setLivello] = useState<LivelloSchwartz>('L3')
|
||||||
|
const [nicchia, setNicchia] = useState('generico')
|
||||||
|
const [formato, setFormato] = useState<FormatoNarrativo>('PAS')
|
||||||
|
|
||||||
|
// Risultato
|
||||||
|
const [result, setResult] = useState<PostResult | null>(null)
|
||||||
|
const [localResult, setLocalResult] = useState<PostResult | null>(null)
|
||||||
|
|
||||||
|
const jobDate = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!apiKeyOk || !obiettivo.trim()) return
|
||||||
|
|
||||||
|
const slot: CalendarSlot = {
|
||||||
|
indice: 0,
|
||||||
|
tipo_contenuto: tipoPN,
|
||||||
|
livello_schwartz: livello,
|
||||||
|
formato_narrativo: formato,
|
||||||
|
funzione: 'Educare',
|
||||||
|
fase_campagna: 'Coinvolgi',
|
||||||
|
target_nicchia: nicchia,
|
||||||
|
data_pub_suggerita: jobDate,
|
||||||
|
topic: topic.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await generateMutation.mutateAsync({
|
||||||
|
slot,
|
||||||
|
obiettivo_campagna: obiettivo.trim(),
|
||||||
|
})
|
||||||
|
const withSlot = { ...res, slot }
|
||||||
|
setResult(withSlot)
|
||||||
|
setLocalResult(withSlot)
|
||||||
|
} catch {
|
||||||
|
// Errore gestito da generateMutation.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!localResult?.post) return
|
||||||
|
await downloadMutation.mutateAsync({
|
||||||
|
jobId: 'single-' + Date.now(),
|
||||||
|
results: [localResult],
|
||||||
|
campagna: obiettivo.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-10 space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-stone-100">Genera Singolo Post</h1>
|
||||||
|
<p className="mt-1 text-stone-400 text-sm">
|
||||||
|
Genera un carosello Instagram con parametri personalizzati. Utile per test e rigenerazione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner API key */}
|
||||||
|
{!apiKeyOk && (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||||
|
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-stone-400">
|
||||||
|
<Link to="/impostazioni" className="text-amber-400 underline underline-offset-2">
|
||||||
|
Configura la tua API key
|
||||||
|
</Link>{' '}
|
||||||
|
per abilitare la generazione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Obiettivo campagna */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">
|
||||||
|
Obiettivo campagna <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={obiettivo}
|
||||||
|
onChange={(e) => setObiettivo(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
placeholder="Es. Acquisire nuovi clienti dentisti"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topic specifico */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">
|
||||||
|
Topic specifico{' '}
|
||||||
|
<span className="text-stone-600 font-normal">(opzionale — altrimenti generato dall'AI)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
placeholder="Es. 3 errori che fanno perdere pazienti al tuo studio"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm placeholder-stone-600 focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Tipo PN */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Tipo PN</label>
|
||||||
|
<select
|
||||||
|
value={tipoPN}
|
||||||
|
onChange={(e) => setTipoPN(e.target.value as TipoContenuto)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{TIPI_PN.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Livello Schwartz */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Livello Schwartz</label>
|
||||||
|
<select
|
||||||
|
value={livello}
|
||||||
|
onChange={(e) => setLivello(e.target.value as LivelloSchwartz)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{LIVELLI_SCHWARTZ.map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>{l.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nicchia */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Nicchia target</label>
|
||||||
|
<select
|
||||||
|
value={nicchia}
|
||||||
|
onChange={(e) => setNicchia(e.target.value)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{NICCHIE.map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formato narrativo */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Formato narrativo</label>
|
||||||
|
<select
|
||||||
|
value={formato}
|
||||||
|
onChange={(e) => setFormato(e.target.value as FormatoNarrativo)}
|
||||||
|
disabled={!apiKeyOk}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-700 text-stone-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{FORMATI.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errore */}
|
||||||
|
{generateMutation.error && (
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
||||||
|
{generateMutation.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!apiKeyOk || !obiettivo.trim() || generateMutation.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 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending && <Loader2 size={15} className="animate-spin" />}
|
||||||
|
{generateMutation.isPending ? 'Generazione in corso...' : 'Genera Post'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Risultato */}
|
||||||
|
{result && localResult && (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-stone-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-stone-300">Anteprima risultato</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloadMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-stone-700 text-stone-400 text-xs hover:text-stone-200 hover:border-stone-600 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{downloadMutation.isPending ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={12} />
|
||||||
|
)}
|
||||||
|
Download CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PostCard
|
||||||
|
result={localResult}
|
||||||
|
obiettivoCampagna={obiettivo}
|
||||||
|
onRegenerated={(updated) => setLocalResult(updated)}
|
||||||
|
onEdit={(updated) => setLocalResult(updated)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
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