Files
sistema_base/apps/web/feactures/surveys/components/admin/survey-builder.tsx

454 lines
15 KiB
TypeScript

// Componente principal para crear/editar encuestas
// Funcionalidades:
// - Formulario para datos básicos (título, descripción, fecha de cierre)
// - Sistema de drag & drop para agregar preguntas
// - Reordenamiento de preguntas existentes
// - Guardado como borrador o publicación directa'use client';
'use client';
import { Button } from '@repo/shadcn/button';
import { Card, CardContent } from '@repo/shadcn/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@repo/shadcn/form';
import { Input } from '@repo/shadcn/input';
import { Textarea } from '@repo/shadcn/textarea';
import { CalendarIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { QuestionType, Survey } from '../../schemas/survey';
import { QuestionConfigModal } from './question-config-modal';
import { QuestionToolbox } from './question-toolbox';
import { cn } from '@repo/shadcn/lib/utils';
import { Calendar } from '@repo/shadcn/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@repo/shadcn/popover';
import { format } from 'date-fns';
import { DndContext, DragEndEvent, useSensor, useSensors, PointerSensor } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useDroppable } from '@dnd-kit/core';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn/select";
import { useParams, useRouter } from 'next/navigation';
// Añade el import de Trash2
import { Trash2 } from 'lucide-react';
import { useSurveysByIdQuery } from '../../hooks/use-query-surveys';
import { useSurveyMutation } from '../../hooks/use-mutation-surveys';
function SortableQuestion({
question,
index,
onDelete,
onEdit
}: {
question: any;
index: number;
onDelete: (id: string) => void;
onEdit: (question: any) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: question.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Card className="mb-4">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div
className="flex-1 cursor-pointer"
onClick={() => onEdit(question)}
>
<span>{question.question || question.content}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
onDelete(question.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function DroppableArea({ children }: { children: React.ReactNode }) {
const { setNodeRef } = useDroppable({
id: 'questions-container',
});
return (
<div
ref={setNodeRef}
className="min-h-[200px] border-2 border-dashed rounded-lg p-4 mt-6"
>
{children}
</div>
);
}
export function SurveyBuilder() {
const [questions, setQuestions] = useState<any[]>([]);
const [selectedQuestion, setSelectedQuestion] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const params = useParams();
const router = useRouter();
const surveyId = params?.id as string;
const isEditing = Boolean(surveyId);
const form = useForm({
defaultValues: {
title: '',
description: '',
closingDate: undefined as Date | undefined,
targetAudience: '', // Nuevo campo
},
});
const {
mutate: MutateSurvey,
} = useSurveyMutation()
// Remove the loadSurvey function and use the query hook at component level
if (isEditing) {
const { data: surveyById, isLoading } = useSurveysByIdQuery(parseInt(surveyId))
// Use useEffect to handle the form reset when data is available
useEffect(() => {
// console.log(isEditing ? parseInt(surveyId) : 0);
if (surveyById?.data && !isLoading) {
form.reset({
title: surveyById.data.title,
description: surveyById.data.description,
closingDate: surveyById.data.closingDate || undefined,
targetAudience: surveyById.data.targetAudience,
});
// Fix: Set the questions directly without wrapping in array
setQuestions(surveyById.data.questions || []);
}
}, [surveyById, isLoading, form]);
}
// Remove the loadSurvey() call from the component body
// Procesa la configuración de una pregunta después de cerrar el modal
// Actualiza o agrega la pregunta al listado
const handleQuestionConfig = (questionConfig: any) => {
if (selectedQuestion) {
const updatedQuestions = [...questions];
const index = updatedQuestions.findIndex(q => q.id === selectedQuestion.id);
if (index === -1) {
updatedQuestions.push({
...selectedQuestion,
...questionConfig,
});
} else {
updatedQuestions[index] = {
...selectedQuestion,
...questionConfig,
};
}
setQuestions(updatedQuestions);
}
setIsModalOpen(false);
};
// Maneja el guardado de la encuesta
// Valida campos requeridos y guarda como borrador o publicada
const handleSave = async (status: 'draft' | 'published') => {
const formData = form.getValues();
// validar que los campos no esten vacíos
if (!formData.title) return toast.error('El título es obligatorio')
if (!formData.description) return toast.error('La descripción es obligatorio')
if (!formData.targetAudience) return toast.error('El público objetivo es obligatorio')
if (!formData.closingDate) return toast.error('La fecha de cierre es obligatorio')
if (questions.length === 0) return toast.error('Debe agregar al menos una pregunta');
const surveyData: Omit<Survey, 'created_at'> = {
title: formData.title,
description: formData.description,
closingDate: formData.closingDate,
targetAudience: formData.targetAudience,
published: status === 'published',
questions: questions.map((q, index) => ({ ...q, position: index })),
};
try {
await MutateSurvey({
...surveyData,
id: isEditing ? parseInt(surveyId) : undefined,
}, {
onSuccess: () => {
toast.success(
isEditing
? 'Encuesta actualizada exitosamente'
: status === 'published'
? 'Encuesta publicada'
: 'Encuesta guardada como borrador'
);
router.push('/dashboard/administracion/encuestas');
},
onError: (e) => {
toast.error(e.message)
}
});
} catch (error) {
toast.error( `Error al ${isEditing ? 'actualizar' : 'guardar'} la encuesta`)
}
};
// Configuración de los sensores para el drag and drop
// Define la distancia mínima para activar el arrastre
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
// Manejador del evento cuando se termina de arrastrar un elemento
// Gestiona tanto nuevas preguntas como reordenamiento
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (active.data.current?.isTemplate) {
// Handle new question from toolbox
const questionType = active.data.current.type;
const newQuestion = {
id: `q-${questions.length + 1}`,
type: questionType as QuestionType,
position: questions.length,
required: false,
};
setSelectedQuestion(newQuestion);
setIsModalOpen(true);
} else {
// Handle reordering of existing questions
const oldIndex = questions.findIndex(q => q.id === active.id);
const newIndex = questions.findIndex(q => q.id === over.id);
if (oldIndex !== newIndex) {
const updatedQuestions = [...questions];
const [movedQuestion] = updatedQuestions.splice(oldIndex, 1);
updatedQuestions.splice(newIndex, 0, movedQuestion);
setQuestions(updatedQuestions);
}
}
};
// Añade estas funciones manejadoras
const handleDeleteQuestion = (id: string) => {
setQuestions(questions.filter(q => q.id !== id));
};
const handleEditQuestion = (question: any) => {
setSelectedQuestion(question);
setIsModalOpen(true);
};
return (
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
>
<div className="flex gap-6">
<div className="w-64">
<QuestionToolbox />
</div>
<div className="flex-1">
<Card>
<CardContent className="p-6">
<Form {...form}>
<form className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Título de la Encuesta</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="closingDate"
render={({ field }) => (
<FormItem>
<FormLabel>Fecha de Cierre</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Seleccione una fecha</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date < new Date()
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetAudience"
render={({ field }) => (
<FormItem>
<FormLabel>Dirigido a</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleccione el público objetivo" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="producers">Productores</SelectItem>
<SelectItem value="organization">Organización</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<DroppableArea>
<SortableContext
items={questions.map(q => q.id)}
strategy={verticalListSortingStrategy}
>
{questions.map((question, index) => (
<SortableQuestion
key={question.id}
question={question}
index={index}
onDelete={handleDeleteQuestion}
onEdit={handleEditQuestion}
/>
))}
{questions.length === 0 && (
<div className="text-center text-muted-foreground p-4">
Arrastra elementos aquí para crear la encuesta
</div>
)}
</SortableContext>
</DroppableArea>
<div className="flex justify-end gap-4 mt-6">
<Button
variant="ghost"
onClick={() => router.push('/dashboard/administracion/encuestas')}
>
Cancelar
</Button>
<Button variant="outline" onClick={() => handleSave('draft')}>
Guardar como Borrador
</Button>
<Button onClick={() => handleSave('published')}>
Publicar
</Button>
</div>
</CardContent>
</Card>
</div>
<QuestionConfigModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
question={selectedQuestion}
onSave={handleQuestionConfig}
/>
</div>
</DndContext>
);
}