Actualizar flujo CDC Brief: campaña existente y revisión final

This commit is contained in:
Isaac Aracena
2026-05-08 10:31:17 -04:00
parent fd2d11c436
commit 21f31a0c35
3 changed files with 210 additions and 30 deletions
+10 -7
View File
@@ -20,17 +20,19 @@ Este portal funciona por pasos y no como un chat libre.
1. **Nota de voz** _(obligatorio)_ 1. **Nota de voz** _(obligatorio)_
2. **Imágenes de referencia** _(obligatorio)_ 2. **Imágenes de referencia** _(obligatorio)_
3. **Documentos relacionados y archivos de apoyo** _(opcional)_ 3. **Documentos relacionados** _(opcional)_
4. **Enlaces de referencia online** _(opcional)_ 4. **Campaña o elementos gráficos existentes** _(opcional)_
5. **Enlaces de referencia online** _(opcional)_
6. **Revisión y envío**
**Qué hace el sistema con cada entrada:** **Qué hace el sistema con cada entrada:**
- **Nota de voz:** se analiza - **Nota de voz:** se analiza
- **Imágenes:** se analizan - **Imágenes:** se analizan
- **PDF, Excel, PowerPoint, Word y otros archivos:** se guardan en la carpeta del proyecto - **Documentos y Campaña:** se guardan en la carpeta del proyecto
- **Enlaces online:** se listan dentro del brief - **Enlaces online:** se listan dentro del brief
Cuando completes los pasos obligatorios, podrás enviar la solicitud.`; Cuando completes los pasos obligatorios, podrás enviar la solicitud en el paso final.`;
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([
{ {
@@ -145,7 +147,8 @@ Cuando completes los pasos obligatorios, podrás enviar la solicitud.`;
formData.append('timestamp', new Date().toISOString()); formData.append('timestamp', new Date().toISOString());
attachments.forEach((att, index) => { attachments.forEach((att, index) => {
formData.append(`file_${index}`, att.file); const key = att.category === 'campaign' ? `campaign_file_${index}` : `file_${index}`;
formData.append(key, att.file);
}); });
if (audio) { if (audio) {
@@ -288,8 +291,8 @@ Verifica que la URL productiva esté correcta en el entorno y que el endpoint es
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{[ {[
'La nota de voz y las imágenes sí se analizan automáticamente.', 'La nota de voz, las imágenes y los PDFs se analizan automáticamente.',
'PDF, Excel, PowerPoint, Word y otros adjuntos se guardan en la carpeta del proyecto.', 'Excel, PowerPoint, Word y otros adjuntos se guardan como soporte en la carpeta del proyecto.',
'Los enlaces online se registran para alimentar el brief final.', 'Los enlaces online se registran para alimentar el brief final.',
'El usuario no necesita usar el portal como chat libre: todo queda guiado.', 'El usuario no necesita usar el portal como chat libre: todo queda guiado.',
].map((item) => ( ].map((item) => (
+199 -23
View File
@@ -23,7 +23,7 @@ interface InputAreaProps {
isProcessing: boolean; isProcessing: boolean;
} }
type GuidedStep = 'audio' | 'images' | 'documents' | 'links'; type GuidedStep = 'audio' | 'images' | 'documents' | 'campaign' | 'links' | 'review';
interface StepDefinition { interface StepDefinition {
id: GuidedStep; id: GuidedStep;
@@ -52,14 +52,28 @@ const STEP_DEFINITIONS: StepDefinition[] = [
id: 'documents', id: 'documents',
number: 3, number: 3,
title: 'Documentos relacionados', title: 'Documentos relacionados',
hint: 'Opcional. Adjunta PDF, Excel, PowerPoint, Word u otros archivos de apoyo.', hint: 'Opcional. Adjunta archivos de apoyo.',
required: false,
},
{
id: 'campaign',
number: 4,
title: 'Campaña o elementos gráficos',
hint: 'Opcional. Si ya existen piezas gráficas o logos.',
required: false, required: false,
}, },
{ {
id: 'links', id: 'links',
number: 4, number: 5,
title: 'Enlaces de referencia online', title: 'Enlaces de referencia',
hint: 'Opcional. Pega enlaces, uno por línea.', hint: 'Opcional. Pega enlaces online.',
required: false,
},
{
id: 'review',
number: 6,
title: 'Revisión y envío',
hint: 'Finaliza tu solicitud aquí.',
required: false, required: false,
}, },
]; ];
@@ -71,6 +85,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [audioRecording, setAudioRecording] = useState<AudioRecording | null>(null); const [audioRecording, setAudioRecording] = useState<AudioRecording | null>(null);
const [currentStep, setCurrentStep] = useState<GuidedStep>('audio'); const [currentStep, setCurrentStep] = useState<GuidedStep>('audio');
const [hasCampaign, setHasCampaign] = useState<'yes' | 'no' | null>(null);
const [validationMessage, setValidationMessage] = useState<string | null>(null); const [validationMessage, setValidationMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -84,7 +99,11 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
[attachments], [attachments],
); );
const documentAttachments = useMemo( const documentAttachments = useMemo(
() => attachments.filter((att) => att.type !== 'image'), () => attachments.filter((att) => att.type !== 'image' && att.category !== 'campaign'),
[attachments],
);
const campaignAttachments = useMemo(
() => attachments.filter((att) => att.category === 'campaign'),
[attachments], [attachments],
); );
@@ -109,8 +128,14 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
return imageAttachments.length > 0 ? 'done' : currentStep === 'images' ? 'active' : 'pending'; return imageAttachments.length > 0 ? 'done' : currentStep === 'images' ? 'active' : 'pending';
case 'documents': case 'documents':
return documentAttachments.length > 0 ? 'done' : currentStep === 'documents' ? 'active' : 'pending'; return documentAttachments.length > 0 ? 'done' : currentStep === 'documents' ? 'active' : 'pending';
case 'campaign':
if (hasCampaign === 'no') return 'done';
if (hasCampaign === 'yes' && campaignAttachments.length > 0) return 'done';
return currentStep === 'campaign' ? 'active' : 'pending';
case 'links': case 'links':
return text.trim() ? 'done' : currentStep === 'links' ? 'active' : 'pending'; return text.trim() ? 'done' : currentStep === 'links' ? 'active' : 'pending';
case 'review':
return currentStep === 'review' ? 'active' : 'pending';
default: default:
return 'pending'; return 'pending';
} }
@@ -158,6 +183,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
file, file,
type: inferAttachmentType(file), type: inferAttachmentType(file),
url: URL.createObjectURL(file), url: URL.createObjectURL(file),
category: currentStep === 'campaign' ? 'campaign' : 'general',
})); }));
setAttachments((prev) => [...prev, ...newAttachments]); setAttachments((prev) => [...prev, ...newAttachments]);
@@ -254,9 +280,27 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
} }
if (currentStep === 'documents') { if (currentStep === 'documents') {
advanceTo('campaign');
return;
}
if (currentStep === 'campaign') {
if (hasCampaign === null) {
setValidationMessage('Por favor responde si ya existe campaña o elementos gráficos.');
return;
}
if (hasCampaign === 'yes' && campaignAttachments.length === 0) {
setValidationMessage('Si seleccionaste que sí existe campaña, debes adjuntar al menos un archivo.');
return;
}
advanceTo('links'); advanceTo('links');
return; return;
} }
if (currentStep === 'links') {
advanceTo('review');
return;
}
}; };
const buildSubmissionText = () => { const buildSubmissionText = () => {
@@ -268,11 +312,19 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
const summaryLines = [ const summaryLines = [
'Solicitud enviada desde el portal guiado del Brief CDC.', 'Solicitud enviada desde el portal guiado del Brief CDC.',
`Nota de voz: ${audioRecording ? 'sí' : 'no'}`, `Nota de voz: ${audioRecording ? 'Sí' : 'No'}`,
`Imágenes de referencia: ${imageAttachments.length}`, `Imágenes de referencia: ${imageAttachments.length}`,
`Documentos relacionados: ${documentAttachments.length}`, `Documentos relacionados: ${documentAttachments.length}`,
`¿Existe campaña o elementos gráficos?: ${hasCampaign === 'yes' ? 'Sí' : 'No'}`,
]; ];
if (hasCampaign === 'yes') {
summaryLines.push(`Archivos de campaña adjuntos: ${campaignAttachments.length}`);
campaignAttachments.forEach((att) => {
summaryLines.push(`- ${att.file.name}`);
});
}
if (linksText) { if (linksText) {
summaryLines.push('', 'Enlaces de referencia:', linksText); summaryLines.push('', 'Enlaces de referencia:', linksText);
} }
@@ -281,8 +333,13 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
}; };
const handleSend = () => { const handleSend = () => {
if (currentStep !== 'review') {
advanceTo('review');
return;
}
if (!requiredCompleted) { if (!requiredCompleted) {
setValidationMessage('Completa primero la nota de voz y al menos una imagen de referencia.'); setValidationMessage('Completa primero los pasos obligatorios (voz e imágenes).');
return; return;
} }
@@ -290,6 +347,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
onSendMessage(buildSubmissionText(), attachments, audioRecording || undefined); onSendMessage(buildSubmissionText(), attachments, audioRecording || undefined);
setText(''); setText('');
setAttachments([]); setAttachments([]);
setHasCampaign(null);
if (audioRecording?.url) { if (audioRecording?.url) {
URL.revokeObjectURL(audioRecording.url); URL.revokeObjectURL(audioRecording.url);
} }
@@ -326,7 +384,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
/> />
</div> </div>
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-6">
{STEP_DEFINITIONS.map((step) => { {STEP_DEFINITIONS.map((step) => {
const status = getStepStatus(step.id); const status = getStepStatus(step.id);
return ( return (
@@ -335,7 +393,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
type="button" type="button"
onClick={() => advanceTo(step.id)} onClick={() => advanceTo(step.id)}
className={cn( className={cn(
'group relative overflow-hidden rounded-[24px] border p-4 text-left transition-all', 'group relative flex h-full flex-col overflow-hidden rounded-[24px] border p-4 text-left transition-all',
status === 'done' && 'border-emerald-400/25 bg-emerald-500/10', status === 'done' && 'border-emerald-400/25 bg-emerald-500/10',
status === 'active' && 'border-indigo-400/30 bg-[linear-gradient(180deg,rgba(99,102,241,0.18),rgba(59,130,246,0.08))] shadow-[0_18px_40px_rgba(79,70,229,0.18)]', status === 'active' && 'border-indigo-400/30 bg-[linear-gradient(180deg,rgba(99,102,241,0.18),rgba(59,130,246,0.08))] shadow-[0_18px_40px_rgba(79,70,229,0.18)]',
status === 'pending' && 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/25' status === 'pending' && 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/25'
@@ -353,8 +411,8 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
<div className="h-3 w-3 rounded-full bg-zinc-600" /> <div className="h-3 w-3 rounded-full bg-zinc-600" />
)} )}
</div> </div>
<h4 className="text-sm font-semibold text-white">{step.title}</h4> <h4 className="mb-2 min-h-[2.5rem] text-sm font-semibold text-white">{step.title}</h4>
<p className="mt-2 text-xs leading-6 text-zinc-400">{step.hint}</p> <p className="min-h-[3rem] text-xs leading-5 text-zinc-400">{step.hint}</p>
</button> </button>
); );
})} })}
@@ -489,6 +547,67 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
</div> </div>
)} )}
{currentStep === 'campaign' && (
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="mb-5 text-sm leading-7 text-zinc-300">
¿Ya existe campaña o elementos gráficos para esta solicitud? (logos, key visuals, piezas previas, etc.)
</p>
<div className="flex flex-wrap gap-4 mb-6">
<button
onClick={() => {
setHasCampaign('yes');
setValidationMessage(null);
}}
className={cn(
"px-6 py-3 rounded-2xl font-semibold transition-all border",
hasCampaign === 'yes'
? "bg-indigo-500/20 border-indigo-400 text-white shadow-[0_0_20px_rgba(99,102,241,0.2)]"
: "bg-black/20 border-white/10 text-zinc-400 hover:border-white/20"
)}
>
, ya existen
</button>
<button
onClick={() => {
setHasCampaign('no');
// Remove any campaign attachments if user switches to 'no'
setAttachments(prev => prev.filter(a => a.category !== 'campaign'));
setValidationMessage(null);
}}
className={cn(
"px-6 py-3 rounded-2xl font-semibold transition-all border",
hasCampaign === 'no'
? "bg-indigo-500/20 border-indigo-400 text-white shadow-[0_0_20px_rgba(99,102,241,0.2)]"
: "bg-black/20 border-white/10 text-zinc-400 hover:border-white/20"
)}
>
No, empezar de cero
</button>
</div>
{hasCampaign === 'yes' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
>
<div className="flex items-center gap-3 text-sm text-indigo-300 mb-2">
<AlertCircle className="h-4 w-4" />
<span>Debes adjuntar al menos un archivo de la campaña.</span>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3.5 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.25)] transition hover:brightness-110 disabled:opacity-50"
disabled={isProcessing || isRecording}
>
<Paperclip className="h-4 w-4" />
Adjuntar elementos de campaña
</button>
</motion.div>
)}
</div>
)}
{currentStep === 'links' && ( {currentStep === 'links' && (
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4"> <div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="mb-5 text-sm leading-7 text-zinc-300"> <p className="mb-5 text-sm leading-7 text-zinc-300">
@@ -510,6 +629,57 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
</div> </div>
)} )}
{currentStep === 'review' && (
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="mb-5 text-sm leading-7 text-zinc-300">
Revisa los detalles de tu solicitud antes de enviarla.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h4 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">Contenido Principal</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Nota de Voz:</span>
<span className={cn("font-medium", audioRecording ? "text-emerald-400" : "text-amber-400")}>
{audioRecording ? "Cargada" : "Faltante"}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Imágenes:</span>
<span className={cn("font-medium", imageAttachments.length > 0 ? "text-emerald-400" : "text-amber-400")}>
{imageAttachments.length} archivo(s)
</span>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h4 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">Archivos Adicionales</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Documentos:</span>
<span className="text-zinc-200 font-medium">{documentAttachments.length} archivo(s)</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Campaña Previa:</span>
<span className="text-zinc-200 font-medium">
{hasCampaign === 'yes' ? `${campaignAttachments.length} archivo(s)` : 'No aplica'}
</span>
</div>
</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<h4 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">Enlaces y Referencias</h4>
<p className="text-sm text-zinc-300 italic whitespace-pre-wrap">
{text.trim() ? text : "Sin enlaces agregados"}
</p>
</div>
</div>
)}
<input <input
type="file" type="file"
multiple multiple
@@ -534,7 +704,7 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{currentStep !== 'links' ? ( {currentStep !== 'review' ? (
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={isProcessing} disabled={isProcessing}
@@ -543,16 +713,16 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
Continuar Continuar
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</button> </button>
) : null} ) : (
<button
<button onClick={handleSend}
onClick={handleSend} disabled={isProcessing || !requiredCompleted}
disabled={isProcessing || !requiredCompleted} className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.2)] transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex items-center gap-2 rounded-2xl bg-[linear-gradient(135deg,#6366f1_0%,#2563eb_100%)] px-5 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(79,70,229,0.2)] transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50" >
> {isProcessing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} Finalizar y enviar
Finalizar y enviar </button>
</button> )}
</div> </div>
</div> </div>
</div> </div>
@@ -584,6 +754,12 @@ export const InputArea: React.FC<InputAreaProps> = ({ onSendMessage, isProcessin
<span>Documentos</span> <span>Documentos</span>
<span className="font-semibold text-white">{documentAttachments.length}</span> <span className="font-semibold text-white">{documentAttachments.length}</span>
</div> </div>
<div className="flex items-center justify-between">
<span>Campaña</span>
<span className={cn('font-semibold', hasCampaign === 'yes' ? 'text-emerald-300' : 'text-zinc-500')}>
{hasCampaign === 'yes' ? `${campaignAttachments.length} archivo(s)` : hasCampaign === 'no' ? 'No aplica' : 'Pendiente'}
</span>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Enlaces</span> <span>Enlaces</span>
<span className="font-semibold text-white">{text.split('\n').map(line => line.trim()).filter(Boolean).length}</span> <span className="font-semibold text-white">{text.split('\n').map(line => line.trim()).filter(Boolean).length}</span>
+1
View File
@@ -5,6 +5,7 @@ export interface Attachment {
file: File; file: File;
type: AttachmentType; type: AttachmentType;
url: string; // Object URL for preview url: string; // Object URL for preview
category?: 'general' | 'campaign';
} }
export interface AudioRecording { export interface AudioRecording {