Initial commit

This commit is contained in:
Rafael Dias Martins
2026-08-31 14:10:20 -03:00
commit 8fcb5aac66
454 changed files with 60207 additions and 0 deletions
@@ -0,0 +1,335 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useActionState } from "react";
import saveAffiliateProductAction from "@/app/lib/affiliateProducts/saveAffiliateProductAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
import { XMarkIcon, PhotoIcon } from "@heroicons/react/24/outline";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { MAX_FILE_SIZE } from "@/app/lib/constants";
function AffiliateProductForm({ product = {}, classTypes = [], categories = [], onCancel }) {
const router = useRouter();
const fileInputRef = useRef(null);
const [showMessage, setShowMessage] = useState(true);
const [selectedClassTypes, setSelectedClassTypes] = useState(
product.classTypes?.map((ct) => ct.toString()) || []
);
const [imageInputType, setImageInputType] = useState(
product.imageUrl?.startsWith("http") ? "url" : "upload"
);
const [imagePreview, setImagePreview] = useState(product.imageUrl || "");
const [uploadedFileName, setUploadedFileName] = useState("");
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveAffiliateProductAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success && state.redirectTo) {
const timer = setTimeout(() => {
router.push(state.redirectTo);
}, 1500);
return () => clearTimeout(timer);
}
}, [state?.success, state?.redirectTo, router]);
const handleClassTypeToggle = (classTypeId) => {
setSelectedClassTypes((prev) => {
if (prev.includes(classTypeId)) {
return prev.filter((id) => id !== classTypeId);
} else {
return [...prev, classTypeId];
}
});
};
const handleFileChange = (e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
alert(`Arquivo "${file.name}" (${sizeMB}MB) excede o limite de 500MB.`);
e.target.value = "";
return;
}
setUploadedFileName(file.name);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const clearImage = () => {
setImagePreview("");
setUploadedFileName("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="w-full max-w-2xl mx-auto">
{state?.message && showMessage && (
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
)}
<form
action={action}
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md"
>
{isPending && <p className="text-center mb-4">Carregando...</p>}
{product._id && (
<input type="hidden" name="_id" value={product._id} />
)}
{/* Hidden inputs for selected class types */}
{selectedClassTypes.map((classTypeId) => (
<input
key={classTypeId}
type="hidden"
name="classTypeIds"
value={classTypeId}
/>
))}
<div className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Título <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
defaultValue={product.title || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Ex: Livro de Gramática Essential"
required
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Descrição
</label>
<textarea
id="description"
name="description"
defaultValue={product.description || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
rows="3"
placeholder="Breve descrição do produto..."
></textarea>
</div>
{/* Image - Upload or URL */}
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Imagem do Produto
</label>
{/* Toggle between upload and URL */}
<div className="flex gap-4 mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="upload"
checked={imageInputType === "upload"}
onChange={() => setImageInputType("upload")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Fazer upload</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="url"
checked={imageInputType === "url"}
onChange={() => setImageInputType("url")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Usar URL</span>
</label>
</div>
{/* Upload option */}
{imageInputType === "upload" && (
<div>
<input
ref={fileInputRef}
type="file"
id="imageFile"
name="imageFile"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
/>
{uploadedFileName && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Arquivo selecionado: {uploadedFileName}
</p>
)}
</div>
)}
{/* URL option */}
{imageInputType === "url" && (
<input
type="url"
id="imageUrl"
name="imageUrl"
defaultValue={product.imageUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://example.com/image.jpg"
/>
)}
{/* Image Preview */}
{imagePreview && (
<div className="mt-3 relative">
<img
src={imagePreview}
alt="Preview"
className="w-32 h-32 object-cover rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
title="Remover imagem"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Affiliate URL */}
<div>
<label htmlFor="affiliateUrl" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Link de Afiliado <span className="text-red-500">*</span>
</label>
<input
type="url"
id="affiliateUrl"
name="affiliateUrl"
defaultValue={product.affiliateUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://amazon.com.br/..."
required
/>
</div>
{/* Price and Category */}
<div>
<label htmlFor="category" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Categoria
</label>
<input
type="text"
id="category"
name="category"
defaultValue={product.category || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Ex: Livros, Materiais, etc."
/>
</div>
{/* Active */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="active"
name="active"
value="true"
defaultChecked={product.active !== undefined ? product.active : true}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label htmlFor="active" className="text-neutral-700 dark:text-neutral-200 text-sm font-medium">
Produto ativo
</label>
</div>
{/* Class Types */}
{classTypes.length > 0 && (
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Tipos de Turma Relacionados
</label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-700">
{classTypes.map((classType) => (
<button
key={classType._id}
type="button"
onClick={() => handleClassTypeToggle(classType._id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
selectedClassTypes.includes(classType._id)
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:border-indigo-400"
}`}
>
{classType.title}
</button>
))}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
O produto será exibido para estas turmas
</p>
</div>
)}
</div>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
</div>
);
}
export default AffiliateProductForm;
@@ -0,0 +1,140 @@
"use client";
import { deleteAffiliateProductAction } from "@/app/lib/affiliateProducts/deleteAffiliateProductAction";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function AffiliateProductsTable({ products = [] }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteAffiliateProductAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Produto
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
Categoria
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Status
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
{products.length === 0 ? (
<tr>
<td colSpan="4" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
Nenhum produto cadastrado ainda.
</td>
</tr>
) : (
products.map((product) => (
<tr
key={product._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 align-top text-left">
<div className="flex items-center gap-3">
{product.imageUrl && (
<img
src={product.imageUrl}
alt={product.title}
className="w-12 h-12 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
/>
)}
<div>
<p className="font-medium">{product.title}</p>
{product.description && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 line-clamp-1">
{product.description}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
{product.category || "-"}
</td>
<td className="px-6 py-3.5 text-center align-top">
{product.active ? (
<Label color="emerald" size="sm">Ativo</Label>
) : (
<Label color="red" size="sm">Inativo</Label>
)}
</td>
<td className="px-4 py-3.5 align-top whitespace-nowrap">
<div className="flex items-center gap-2">
<Link
href={`/admin/dashboard/affiliate-products/edit/${product._id.toString()}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="_id"
value={product._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar este produto?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,25 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AffiliateProductForm from "../AffiliateProductForm";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function AddAffiliateProductPage() {
const session = await auth();
if (!session?.user?.roles?.includes("admin")) {
redirect("/auth/login");
}
const classTypes = await getAllClassItems();
return (
<div className="flex flex-col w-full">
<PageHeader
title="Adicionar Produto de Afiliado"
subtitle="Cadastre um novo produto de afiliado"
/>
<AffiliateProductForm classTypes={classTypes} />
</div>
);
}
@@ -0,0 +1,47 @@
import AffiliateProductForm from "@/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductForm";
import { getAffiliateProductById } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditAffiliateProductPage({ params }) {
const session = await auth();
if (!session?.user?.roles?.includes("admin")) {
redirect("/auth/login");
}
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const productResult = await getAffiliateProductById(id);
const product = productResult.success ? productResult.data : null;
const classTypes = await getAllClassItems();
if (!product) {
redirect("/admin/dashboard/affiliate-products");
}
product._id = product._id.toString();
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Produto de Afiliado"
subtitle="Atualize as informações do produto"
/>
<div className="flex justify-center">
<AffiliateProductForm product={product} classTypes={classTypes} />
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AffiliateProductsTable from "./AffiliateProductsTable";
import { getAllAffiliateProductsForAdmin } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
export default async function AffiliateProductsPage() {
const result = await getAllAffiliateProductsForAdmin();
const products = result.success ? result.data : [];
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Produtos de Afiliados"
subtitle="Gerencie os produtos de afiliados que serão exibidos para os estudantes"
actions={
<Link href="/admin/dashboard/affiliate-products/add">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Produto
</button>
</Link>
}
/>
<AffiliateProductsTable products={products} />
</div>
);
}
@@ -0,0 +1,81 @@
import { getExamAssignmentById } from "@/app/lib/actions/examActions";
import MainSection from "@/app/(protected)/components/shared/Main";
import ExamResults from "@/app/(protected)/components/teacher/ExamResults";
import { auth } from "@/app/lib/utils/auth";
import { redirect } from "next/navigation";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { getClassModel } from "@/app/models/Class";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function AdminAssignmentResultsPage({ params }) {
const { id: assignmentId } = await params || {};
if (!isValidObjectId(assignmentId)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
</MainSection>
);
}
const session = await auth();
if (!session?.user?.id) {
redirect("/auth/login");
}
const userRoles = Array.isArray(session.user.roles) ? session.user.roles : [];
const isAdmin =
userRoles.includes("admin") ||
userRoles.includes("superadmin") ||
session.user.role === "admin" ||
session.user.role === "superadmin";
if (!isAdmin) {
return <NotAuthorized />;
}
const result = await getExamAssignmentById(assignmentId);
if (!result.success) {
return (
<MainSection>
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Resultados da prova
</h1>
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
{result.error || "Não foi possível carregar os resultados desta prova."}
</p>
</div>
</MainSection>
);
}
const assignment = result.success ? result.data : null;
// Check if current user is a teacher of the class
let canGrade = false;
if (session?.user?.id && assignment?.classId) {
const Class = await getClassModel();
const classRef = assignment.classId?._id || assignment.classId;
const classData = await Class.findById(classRef).lean();
const teacherIds = classData?.teachers?.map((t) => t.toString()) || [];
canGrade = teacherIds.includes(session.user.id);
}
return (
<MainSection>
<div className="max-w-6xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{assignment?.title || "Prova"}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Resultados e correção das provas
</p>
</div>
<ExamResults assignmentId={assignmentId} assignmentTitle={assignment?.title} canGrade={canGrade} />
</div>
</MainSection>
);
}
@@ -0,0 +1,30 @@
import { getExamAssignments, getExamTemplates, getClasses } from '@/app/lib/actions/examActions';
import AssignmentList from '@/app/(protected)/components/teacher/AssignmentList';
import AssignmentFormWrapper from '@/app/(protected)/components/exam-templates/AssignmentFormWrapper';
export default async function AssignmentsPage() {
const [assignmentsResult, templatesResult, classesResult] = await Promise.all([
getExamAssignments(),
getExamTemplates(),
getClasses()
]);
const assignments = assignmentsResult.success ? assignmentsResult.data : [];
const templates = templatesResult.success ? templatesResult.data : [];
const classes = classesResult.success ? classesResult.data : [];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Exam Assignments</h1>
</div>
<AssignmentList
initialAssignments={assignments}
basePath="/admin/dashboard"
/>
<AssignmentFormWrapper templates={templates} classes={classes} />
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { deleteCategory } from "@/app/lib/categories/deleteCategory";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function CategoriesTable({ categories }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteCategory,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Nome da Categoria
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Descrição
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{categories.map((category) => (
<tr
key={category._id}
className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
{category.name}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{category.description || "-"}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
<Link
href={`/admin/dashboard/categories/edit/${category._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="categoryId"
value={category._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar esta categoria?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveCategoryAction from "@/app/lib/categories/saveCategoryAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
function CategoryForm({ category = {}, onCancel }) {
const router = useRouter();
const [showMessage, setShowMessage] = useState(true);
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveCategoryAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success) {
router.push("/admin/dashboard/categories");
}
}, [state?.success, router]);
return (
<div className="w-full">
{state?.message && showMessage && (
<div className="max-w-screen-xl mx-auto w-full">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form
action={action}
className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
>
{isPending && <p>Carregando...</p>}
{category._id && (
<input type="hidden" name="_id" value={category._id} />
)}
<div className="mb-4">
<label htmlFor="name" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
Nome da Categoria:
</label>
<input
type="text"
id="name"
name="name"
defaultValue={category.name || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
placeholder="Ex: Documentos, Imagens, Vídeos"
required
/>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
Descrição:
</label>
<textarea
id="description"
name="description"
defaultValue={category.description || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
rows="3"
placeholder="Descrição opcional da categoria..."
></textarea>
</div>
<div className="flex items-center justify-between">
<button
type="submit"
disabled={isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
<button
type="button"
onClick={onCancel}
className="bg-gray-500 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
>
Cancelar
</button>
</div>
</form>
</div>
);
}
export default CategoryForm;
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import CategoryForm from "../CategoryForm";
function AddCategory() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Categoria" subtitle="Crie uma nova categoria de arquivos" />
<CategoryForm />
</div>
);
}
export default AddCategory;
@@ -0,0 +1,35 @@
import CategoryForm from "@/app/(protected)/admin/dashboard/categories/CategoryForm";
import { getCategoryById } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditCategoryPage({ params }) {
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const category = await getCategoryById(id);
if (!category) {
return <div>Categoria não encontrada</div>;
}
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Categoria"
subtitle="Atualize as informações da categoria"
/>
<div className="flex justify-center">
<CategoryForm category={category} />
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import CategoriesTable from "./CategoriesTable";
import { getAllCategories } from "@/app/lib/helpers/getItems";
export default async function CategoriesPage() {
const categories = await getAllCategories();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Categorias de Arquivos"
subtitle="Organize e gerencie as categorias de arquivos do sistema"
actions={
<Link href={`/admin/dashboard/categories/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Nova Categoria
</button>
</Link>
}
/>
<CategoriesTable categories={categories} />
</div>
);
}
@@ -0,0 +1,27 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
import { getUsersByRole } from "@/app/lib/users/getUsersByRole";
import { getClassTypes } from "@/app/lib/classes/getClassTypes";
async function AddClass() {
const teachersResult = await getUsersByRole(["teacher"]);
const teachers = teachersResult.success ? teachersResult.data : [];
const studentsResult = await getUsersByRole(["student"]);
const students = studentsResult.success ? studentsResult.data : [];
const classTypesResult = await getClassTypes();
const classTypes = classTypesResult.success ? classTypesResult.data : [];
return (
<div className="flex flex-col w-full px-4 sm:px-6 lg:px-8">
<PageHeader title="Adicionar Turma" subtitle="Crie uma nova turma no sistema" />
<ClassForm
classTypes={classTypes}
teachers={teachers}
students={students}
/>
</div>
);
}
export default AddClass;
@@ -0,0 +1,417 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveClassAction from "@/app/lib/classes/saveClassAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import RoleCheckbox from "@/app/(protected)/components/shared/RoleCheckbox";
import FormCheckbox from "./FormCheckbox";
import { DAYS } from "@/app/lib/utils/days";
function ClassForm({
classData = {},
onCancel,
classTypes = [],
teachers = [],
students = [],
}) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
saveClassAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
const initialData = classData
? {
...classData,
classType:
typeof classData.classType === "object"
? String(classData.classType?._id ?? "")
: String(classData.classType ?? ""),
startDate: classData.startDate?.split("T")[0],
endDate: classData.endDate?.split("T")[0],
}
: {};
const [inputs, setInputs] = useState(state?.inputs || initialData || {});
useEffect(() => {
if (state?.inputs) {
setInputs((prev) => ({
...prev,
...state.inputs,
classType: String(state.inputs.classType ?? ""),
}));
}
}, [state?.inputs]);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 15000);
return () => clearTimeout(timer);
} else {
setShowMessage(false);
}
}, [state.message]);
const onClassTypeChange = (e) => {
const value = String(e.target.value);
const ct = classTypes.find((x) => String(x._id) === e.target.value);
setInputs((prev) => {
const next = { ...prev, classType: value, price: ct?.price ?? "" };
return next;
});
};
const onCheckboxChange = (field, value) => (e) => {
const currentArray = inputs[field] || [];
if (e.target.checked) {
setInputs({ ...inputs, [field]: [...currentArray, value] });
} else {
setInputs({ ...inputs, [field]: currentArray.filter((v) => v !== value) });
}
};
return (
<div className="w-full">
{/* FlashMessage outside form for better visibility */}
{state?.message && showMessage && (
<div className="max-w-4xl mx-auto mb-4">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form action={action} className="max-w-4xl mx-auto">
{isPending && (
<div className="mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-center">
<p className="text-neutral-600 dark:text-neutral-300">Salvando...</p>
</div>
)}
{classData._id && <input type="hidden" name="_id" value={classData._id} />}
{/* Hidden inputs for form submission */}
{(inputs.teachers || []).map((t) => (
<input key={`teacher-${t}`} type="hidden" name="teachers" value={t} />
))}
{(inputs.students || []).map((s) => (
<input key={`student-${s}`} type="hidden" name="students" value={s} />
))}
{(inputs.schedule?.days || []).map((d) => (
<input key={`day-${d}`} type="hidden" name="days" value={d} />
))}
<input type="hidden" name="status" value="active" />
{/* Main Form - Grid Layout */}
<div className="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 shadow-sm overflow-hidden">
{/* Header Section */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{classData._id ? "Editar Turma" : "Nova Turma"}
</h2>
</div>
{/* Form Content */}
<div className="p-6">
{/* 2-Column Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Tipo de Classe */}
<div className="space-y-2">
<label htmlFor="classType" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Tipo de Classe <span className="text-red-500">*</span>
</label>
<select
id="classType"
name="classType"
required
value={inputs?.classType || ""}
onChange={onClassTypeChange}
className="select-contrast w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione...</option>
{classTypes.map((ct) => {
const id = typeof ct._id === "string" ? ct._id : String(ct._id);
return (
<option key={id} value={id}>
{ct.name || ct.title || ct.label || id}
</option>
);
})}
</select>
</div>
{/* Título */}
<div className="space-y-2">
<label htmlFor="classTitle" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Título <span className="text-red-500">*</span>
</label>
<input
type="text"
id="classTitle"
name="classTitle"
value={inputs?.classTitle || ""}
onChange={(e) => setInputs({ ...inputs, classTitle: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Starters 1 - 2025"
/>
</div>
{/* Data de Início */}
<div className="space-y-2">
<label htmlFor="startDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Data de Início <span className="text-red-500">*</span>
</label>
<input
type="date"
lang="pt-BR"
id="startDate"
name="startDate"
value={inputs?.startDate || ""}
onChange={(e) => setInputs({ ...inputs, startDate: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Data de Término */}
<div className="space-y-2">
<label htmlFor="endDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Data de Término
</label>
<input
type="date"
lang="pt-BR"
id="endDate"
name="endDate"
value={inputs?.endDate || ""}
onChange={(e) => setInputs({ ...inputs, endDate: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Horário */}
<div className="space-y-2">
<label htmlFor="time" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Horário <span className="text-red-500">*</span>
</label>
<input
type="time"
id="time"
name="time"
value={inputs?.schedule?.time || ""}
onChange={(e) => setInputs({ ...inputs, schedule: { ...inputs.schedule, time: e.target.value } })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Mensalidade */}
<div className="space-y-2">
<label htmlFor="price" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Mensalidade
</label>
<input
type="text"
id="price"
name="price"
value={inputs?.price || ""}
onChange={(e) => setInputs({ ...inputs, price: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: 50,00"
/>
</div>
{/* Link */}
<div className="space-y-2 md:col-span-2">
<label htmlFor="link" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Link (Zoom, Meet, etc.)
</label>
<input
type="text"
id="link"
name="link"
value={inputs?.link || ""}
onChange={(e) => setInputs({ ...inputs, link: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="https://..."
/>
</div>
</div>
{/* Professores - Full Width */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Professores <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-3">
{teachers.length === 0 ? (
<p className="text-sm text-amber-600 dark:text-amber-400">
Nenhum professor cadastrado.
</p>
) : (
teachers.map((teacher) => {
const id = typeof teacher._id === "string" ? teacher._id : String(teacher._id);
const isChecked = (inputs.teachers || []).some((t) => (typeof t === "string" ? t : String(t)) === id);
return (
<RoleCheckbox
key={id}
value={id}
label={teacher.fullName}
color="indigo"
checked={isChecked}
onChange={onCheckboxChange("teachers", id)}
/>
);
})
)}
</div>
</div>
{/* Alunos - Select Multi with Search (for many students) */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Alunos
</label>
{/* Selected Students as Removable Tags */}
{(inputs.students || []).length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700">
{inputs.students.map((studentId) => {
const idStr = typeof studentId === "string" ? studentId : String(studentId);
const student = students.find((s) => (typeof s._id === "string" ? s._id : String(s._id)) === idStr);
return (
<span
key={idStr}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${
student ? "student-tag" : "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700"
}`}
>
{student ? student.fullName : `ID: ${idStr.slice(-6)}… (removido)`}
<button
type="button"
onClick={() => {
setInputs({
...inputs,
students: (inputs.students || []).filter((s) => (typeof s === "string" ? s : String(s)) !== idStr)
});
}}
>
×
</button>
</span>
);
})}
</div>
)}
{/* Add Students */}
<div className="flex gap-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
setInputs({
...inputs,
students: [...(inputs.students || []), e.target.value]
});
}
}}
className="select-contrast flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">Adicionar aluno...</option>
{students
.filter((s) => !(inputs.students || []).some((selectedId) => (typeof s._id === "string" ? s._id : String(s._id)) === (typeof selectedId === "string" ? selectedId : String(selectedId))))
.map((student) => {
const id = typeof student._id === "string" ? student._id : String(student._id);
return (
<option key={id} value={id}>
{student.fullName}
</option>
);
})
}
</select>
</div>
{students.length === 0 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Nenhum aluno cadastrado.
</p>
)}
</div>
{/* Dias da Semana - Full Width */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Dias da Semana <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-3">
{DAYS.map((day) => {
const isChecked = (inputs.schedule?.days || []).includes(day);
return (
<RoleCheckbox
key={day}
value={day}
label={day}
color="amber"
checked={isChecked}
onChange={(e) => {
const currentDays = inputs.schedule?.days || [];
const newDays = e.target.checked
? [...currentDays, day]
: currentDays.filter((d) => d !== day);
setInputs({ ...inputs, schedule: { ...inputs.schedule, days: newDays } });
}}
/>
);
})}
</div>
</div>
{/* Herdar Arquivos - Only for new classes */}
{!classData._id && (
<div className="mt-6">
<FormCheckbox
name="inheritFiles"
label="Herdar arquivos do tipo de turma"
description="Ao criar a turma, herdam-se os arquivos daquele tipo de classe."
checked={inputs?.inheritFiles ?? true}
onChange={(e) => setInputs({ ...inputs, inheritFiles: e.target.checked })}
/>
</div>
)}
</div>
{/* Action Buttons */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
className="px-6 py-2.5 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</div>
</form>
</div>
);
}
export default ClassForm;
@@ -0,0 +1,28 @@
import { ClassRow } from "@/app/(protected)/admin/dashboard/class/components/classRow";
export default function ClassesTable({classes}) {
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">Título</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">Professores</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell">Início</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-center">Status</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{classes.map((classItem) => (
<ClassRow
key={classItem._id}
classData={classItem}
/>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
ComboboxButton,
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
import { DAYS } from "@/app/lib/utils/days";
export default function DaysMultiSelect({ defaultSelectedDays = [], onRemove }) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(
DAYS.filter((d) => defaultSelectedDays.includes(d))
);
useEffect(() => {
const newSelected = DAYS.filter((d) => defaultSelectedDays.includes(d));
setSelected(newSelected);
}, [defaultSelectedDays]);
const filtered =
query === ""
? DAYS
: DAYS.filter((day) =>
day.toLowerCase().includes(query.toLowerCase())
);
const handleRemove = (day) => {
setSelected((prev) => prev.filter((d) => d !== day));
onRemove && onRemove(day);
};
return (
<div className="mb-4">
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Dias da Semana <span className="text-red-500">*</span>:
</label>
<Combobox
multiple
value={selected}
onChange={setSelected}
onClose={() => setQuery("")}
>
<div className="relative">
<div
className="relative max-w-lg cursor-default overflow-hidden rounded border
border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700
text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<div className="flex flex-wrap gap-1 p-2">
{selected.map((d) => (
<span
key={d}
className="flex items-center gap-1 rounded-full bg-indigo-600 px-2 py-0.5 text-xs
text-white dark:bg-indigo-900/40 dark:text-indigo-300"
>
{d}
<button
type="button"
className="rounded p-0.5 hover:bg-indigo-200 dark:hover:bg-indigo-800"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleRemove(d)}
aria-label={`Remover ${d}`}
>
<CheckIcon className="h-3 w-3 rotate-45" />
</button>
</span>
))}
</div>
<ComboboxInput
aria-label="days"
className="w-full border-none py-2 pl-3 pr-8 text-neutral-900 dark:text-neutral-200 bg-transparent focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder="Selecione os dias..."
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-neutral-400" />
</ComboboxButton>
</div>
<ComboboxOptions
anchor={{ to: "bottom", gap: "0.5rem" }}
className="border mt-1 max-h-60 overflow-auto rounded
border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-800 shadow-lg"
>
{filtered.map((day) => (
<ComboboxOption
key={day}
value={day}
className="data-[focus]:bg-indigo-600 data-[focus]:dark:bg-indigo-600
data-[focus]:dark:text-white cursor-pointer select-none px-3 py-2"
>
{({ selected }) => (
<div className="flex items-center justify-between">
<span>{day}</span>
{selected && <CheckIcon className="ml-1 h-4 w-4" />}
</div>
)}
</ComboboxOption>
))}
</ComboboxOptions>
</div>
</Combobox>
{selected.map((day) => (
<input key={day} type="hidden" name="days" value={day} />
))}
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { FaTrash } from "react-icons/fa";
import { deleteClass } from "@/app/lib/classes/deleteClass";
export default function DeleteClassButton({ classId, classTitle }) {
return (
<form action={deleteClass} className="inline">
<input type="hidden" name="classId" value={classId} />
<button
type="submit"
title="Excluir"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={`Excluir turma ${classTitle}`}
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar esta turma?')) {
e.preventDefault();
}
}}
>
<FaTrash className="text-lg" />
</button>
</form>
);
}
@@ -0,0 +1,50 @@
export default function FormCheckbox({
id,
name,
label,
description,
checked,
onChange,
defaultChecked,
required = false,
disabled = false,
className = "",
}) {
const inputId = id || name;
return (
<div className={`mb-4 ${className}`}>
<div className="flex items-start gap-3">
<input
id={inputId}
name={name}
type="checkbox"
{...(checked !== undefined
? { checked, onChange }
: { defaultChecked })}
required={required}
disabled={disabled}
aria-describedby={description ? `${inputId}-desc` : undefined}
className="mt-1 h-5 w-5 rounded border-neutral-300 dark:border-neutral-600
bg-white dark:bg-neutral-700
accent-indigo-600
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400
disabled:opacity-50"
/>
<div className="leading-tight">
<label
htmlFor={inputId}
className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold"
>
{label}
</label>
{description && (
<p id={`${inputId}-desc`} className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { FaPowerOff } from "react-icons/fa";
import { toggleClassStatus } from "@/app/lib/classes/toggleClassStatus";
import { useState } from "react";
export default function ToggleClassStatusButton({ classId, classTitle, isActive }) {
const [isPending, setIsPending] = useState(false);
const handleToggle = async (e) => {
e.preventDefault();
const action = isActive ? "desativar" : "ativar";
if (!confirm(`Tem certeza que deseja ${action} a turma "${classTitle}"?`)) {
return;
}
setIsPending(true);
try {
const formData = new FormData();
formData.append("classId", classId);
const result = await toggleClassStatus(formData);
if (result.success) {
// Reload the page to show updated status
window.location.reload();
} else {
alert(result.message || "Erro ao alterar status da turma.");
}
} catch (error) {
alert("Erro ao alterar status da turma.");
} finally {
setIsPending(false);
}
};
return (
<button
type="button"
onClick={handleToggle}
disabled={isPending}
title={isActive ? "Desativar" : "Ativar"}
className={`p-2 transition-colors ${
isActive
? "text-emerald-500 hover:text-emerald-700 dark:hover:text-emerald-300"
: "text-neutral-400 hover:text-emerald-600 dark:hover:text-emerald-400"
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
aria-label={`${isActive ? "Desativar" : "Ativar"} turma ${classTitle}`}
>
<FaPowerOff className="text-lg" />
</button>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect } from "react";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
ComboboxButton,
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
export default function UsersMultiSelect({
label = "Usuários",
inputName = "users[]",
users = [],
defaultSelectedIds = [],
onRemove,
onSelectionChange,
}) {
const [query, setQuery] = useState("");
const selected = defaultSelectedIds
.map((id) => users.find((us) => us._id === id))
.filter(Boolean);
const filtered =
query === ""
? users
: users.filter((u) =>
u.fullName.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="mb-4">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
{label}
</label>
<Combobox
multiple
value={selected}
onChange={(newSelected) => onSelectionChange(newSelected.map(u => u._id))}
onClose={() => setQuery("")}
>
<div className="relative">
<div
className="relative max-w-200 cursor-default overflow-hidden rounded-md border border-input bg-background text-left focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 p-1.5">
{selected.map((u) => (
<span
key={u._id}
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground"
>
{u.fullName}
<button
type="button"
className="rounded-full p-0.5 hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onRemove(u._id, inputName)}
aria-label={`Remover ${u.fullName}`}
>
<CheckIcon className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<ComboboxInput
aria-label={label}
displayValue={(user) => user?.fullName}
onChange={(event) => setQuery(event.target.value)}
className="w-full border-none bg-transparent py-2 pl-3 pr-8 text-sm outline-none placeholder:text-muted-foreground"
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted-foreground" />
</ComboboxButton>
</div>
<ComboboxOptions
anchor={{ to: "bottom", gap: "0.25rem" }}
className="border rounded-md shadow-lg max-h-60 overflow-auto bg-popover text-popover-foreground"
>
{filtered.map((u) => (
<ComboboxOption
key={u._id}
value={u}
className="data-[focus]:bg-accent data-[focus]:text-accent-foreground cursor-pointer select-none px-3 py-2"
>
{({ selected }) => (
<div className="flex items-center justify-between">
<span className="truncate">{u.fullName}</span>
{selected && <CheckIcon className="h-4 w-4" />}
</div>
)}
</ComboboxOption>
))}
{filtered.length === 0 && query !== "" && (
<div className="py-2 px-3 text-sm text-muted-foreground">
Nenhum resultado encontrado
</div>
)}
</ComboboxOptions>
</div>
</Combobox>
{selected.map((s) => (
<input key={s._id} type="hidden" name={inputName} value={s._id} />
))}
</div>
);
}
@@ -0,0 +1,71 @@
import { getUserModel } from "@/app/models/User";
import { getFieldItemByItem } from "@/app/lib/helpers/getItems"
import { FaEdit, FaTrash } from "react-icons/fa";
import Link from "next/link";
import { FaFileCirclePlus } from "react-icons/fa6";
import DeleteClassButton from "./DeleteClassButton";
import { deleteClass } from "@/app/lib/classes/deleteClass";
import Label from "@/app/(protected)/components/shared/Label";
import ToggleClassStatusButton from "./ToggleClassStatusButton";
export async function ClassRow({ classData }) {
const User = await getUserModel();
return (
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
<Link
href={`/admin/dashboard/class/files/${classData._id}`}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors underline-offset-2 hover:underline"
title="Abrir dashboard da turma"
>
{classData?.classTitle}
</Link>
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{(
await Promise.all(
classData.teachers.map((t) =>
getFieldItemByItem(User, t._id, "fullName")
)
)
).join(", ")}
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300">
{new Date(classData.startDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-center">
<Label
color={classData.status === 'active' ? 'emerald' : 'red'}
size="sm"
>
{classData.status === 'active' ? 'Ativa' : 'Inativa'}
</Label>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
<ToggleClassStatusButton
classId={classData._id.toString()}
classTitle={classData.classTitle}
isActive={classData.status === 'active'}
/>
<Link
href={`/admin/dashboard/files/class/${classData._id}/add`}
title="Adicionar Arquivo"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaFileCirclePlus className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/class/edit/${classData._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<DeleteClassButton classId={classData._id.toString()} classTitle={classData.classTitle} />
</div>
</td>
</tr>
);
}
@@ -0,0 +1,55 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
import {getUsersByRole} from "@/app/lib/users/getUsersByRole";
import {getClassTypes} from "@/app/lib/classes/getClassTypes";
import {getClassModel} from "@/app/models/Class";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function EditClass({params}) {
const {id} = await params;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const teachersResult = await getUsersByRole(["teacher"]);
const teachers = teachersResult.success ? teachersResult.data : [];
const studentsResult = await getUsersByRole(["student"]);
const students = studentsResult.success ? studentsResult.data : [];
const classTypesResult = await getClassTypes();
const classTypes = classTypesResult.success ? classTypesResult.data : [];
const Class = await getClassModel();
const classData = await Class.findOne({_id: id}).lean();
if (!classData) {
return (
<div className="flex flex-col w-full text-center p-8">
<PageHeader title="Turma não encontrada..." />
<p className="text-gray-600 dark:text-gray-400">
A turma que você está tentando editar não existe ou foi excluída.
</p>
</div>
);
}
//TODO usar a função toPlain
const plainClassData = JSON.parse(JSON.stringify(classData));
return (
<div className="flex flex-col w-full">
<PageHeader title="Editar Turma" subtitle="Atualize as informações da turma" />
<ClassForm
classData={plainClassData || null}
classTypes={classTypes}
teachers={teachers}
students={students}
/>
</div>
);
}
export default EditClass;
@@ -0,0 +1,164 @@
import mongoose from "mongoose";
import MainSection from "@/app/(protected)/components/shared/Main";
import ClassDetail from "@/app/(protected)/components/teacher/ClassDetail";
import { getClassModel } from "@/app/models/Class";
import { getFileModel } from "@/app/models/FilesSchema";
import { getLessonModel } from "@/app/models/Lesson";
import { getExamAttemptModel } from "@/app/models/ExamAttempt";
import { getUserModel } from "@/app/models/User";
import { toPlain } from "@/app/lib/helpers/toPlain";
import { retrieveFiles } from "@/app/lib/helpers/retriveFiles";
async function ClassFilesPage({ params }) {
const { id: classId } = await params;
if (!mongoose.Types.ObjectId.isValid(classId)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Turma não encontrada.</p>
</div>
</MainSection>
);
}
await getFileModel();
await getUserModel();
await getLessonModel();
await getExamAttemptModel();
const ClassModel = await getClassModel();
try {
const theClass = await ClassModel
.findById(classId)
.populate([
{
path: "files",
select: "title url size description uploadedAt mimetype category",
populate: {
path: "category",
select: "name colorIndex"
}
},
{
path: "students",
select: "fullName"
},
{
path: "teachers",
select: "fullName"
}
])
.lean();
if (!theClass) {
return <div>Class not found.</div>;
}
const plainClassData = toPlain(theClass);
const rawFiles = Array.isArray(plainClassData?.files) ? plainClassData.files : [];
const filesData = retrieveFiles(rawFiles);
const totalStudents = new Set((plainClassData?.students || []).map((s) => s._id)).size;
const LessonModel = await getLessonModel();
const lessons = await LessonModel.find({ classId }).sort({ date: -1 }).lean();
const plainLessons = toPlain(lessons);
const classStudentIds = new Set((plainClassData?.students || []).map((s) => s._id));
let totalPresent = 0;
let totalLate = 0;
let totalAttendanceRecords = 0;
const studentAttendance = {};
plainLessons.forEach((lesson) => {
lesson.attendance?.forEach((a) => {
const attendanceStudentId = a?.studentId?._id || a?.studentId;
if (classStudentIds.has(attendanceStudentId)) {
totalAttendanceRecords += 1;
if (a.status === "present") totalPresent += 1;
if (a.status === "late") totalLate += 1;
if (!studentAttendance[attendanceStudentId]) {
studentAttendance[attendanceStudentId] = { present: 0, late: 0, total: 0 };
}
studentAttendance[attendanceStudentId].total += 1;
if (a.status === "present") studentAttendance[attendanceStudentId].present += 1;
if (a.status === "late") studentAttendance[attendanceStudentId].late += 1;
}
});
});
const attendanceRate = totalAttendanceRecords > 0
? Math.round(((totalPresent + totalLate) / totalAttendanceRecords) * 100)
: 0;
const ExamAttempt = await getExamAttemptModel();
const attempts = await ExamAttempt.find({ classId }).lean();
const plainAttempts = toPlain(attempts);
const studentExamStats = {};
plainAttempts.forEach((attempt) => {
const studentId = attempt.studentId?._id || attempt.studentId;
if (!studentExamStats[studentId]) {
studentExamStats[studentId] = { totalScore: 0, totalPoints: 0 };
}
if (attempt.score !== undefined && attempt.totalPoints) {
studentExamStats[studentId].totalScore += attempt.score;
studentExamStats[studentId].totalPoints += attempt.totalPoints;
}
});
const studentsWithData = (plainClassData?.students || []).map((student) => {
const att = studentAttendance[student._id] || { present: 0, late: 0, total: 0 };
const studentAttRate = att.total > 0 ? Math.round(((att.present + att.late) / att.total) * 100) : 0;
const examStats = studentExamStats[student._id] || { totalScore: 0, totalPoints: 0 };
const avgScore = examStats.totalPoints > 0
? (examStats.totalScore / examStats.totalPoints * 10).toFixed(1)
: "-";
return {
...student,
attendanceRate: studentAttRate,
avgScore,
};
});
const cls = {
id: classId,
classTitle: plainClassData?.classTitle || "",
teachers: plainClassData?.teachers?.map(t => t.fullName) || [],
status: plainClassData?.status,
schedule: { days: plainClassData?.schedule?.days || [], time: plainClassData?.schedule?.time || [] },
stats: {
students: totalStudents,
attendanceRate,
avgScore: "-",
pendingSubmissions: plainClassData?.pendingSubmissions || 0,
},
materials: filesData.files || {}, // Pass only the grouped files
};
return (
<MainSection>
<ClassDetail
clsData={cls}
filesData={filesData.files || {}}
categories={filesData.categories || {}}
students={studentsWithData}
classId={classId}
uploadAddPath={`/admin/dashboard/files/class/${classId}/add`}
historyPath={`/admin/dashboard/class/history/${classId}`}
assignmentResultsBasePath="/admin/dashboard"
/>
</MainSection>
);
} catch (err) {
console.error("Error fetching class files:", err);
return <div>Error loading files.</div>;
}
}
export default ClassFilesPage;
@@ -0,0 +1,51 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import ClassHistoryList from "@/app/(protected)/components/teacher/ClassHistoryList";
import { getClassModel } from "@/app/models/Class";
import { getLessonModel } from "@/app/models/Lesson";
import { toPlain } from "@/app/lib/helpers/toPlain";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
import { getUserModel } from "@/app/models/User";
export default async function AdminClassHistoryPage({ params }) {
const { id } = await params;
if (!isValidObjectId(id)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
</MainSection>
);
}
const ClassModel = await getClassModel();
const LessonModel = await getLessonModel();
await getUserModel();
const classData = await ClassModel.findById(id)
.populate("students", "fullName _id")
.lean();
const plainClassData = toPlain(classData);
const lessons = await LessonModel.find({ classId: id })
.sort({ date: -1 })
.populate("teacherId", "fullName")
.populate("attendance.studentId", "fullName")
.lean();
const plainLessons = toPlain(lessons);
return (
<MainSection>
<ClassHistoryList
classId={id}
classTitle={plainClassData?.classTitle || ""}
lessons={plainLessons}
totalStudents={plainClassData?.students?.length || 0}
students={plainClassData?.students || []}
backHref={`/admin/dashboard/class/files/${id}`}
/>
</MainSection>
);
}
@@ -0,0 +1,33 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getClassModel } from "@/app/models/Class";
import ClassesTable from "./components/ClassesTable";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default async function ClassesPage({ searchParams }) {
const params = await searchParams;
const Classes = await getClassModel();
const classes = await Classes.find({}).lean();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Turmas"
subtitle="Gerencie as turmas ativas atualmente, bem como as arquivadas"
actions={
<Link href="/admin/dashboard/class/add">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Nova Turma
</button>
</Link>
}
/>
{params?.warn && (
<div className="mb-4">
<FlashMessage message={decodeURIComponent(params.warn)} type="warning" />
</div>
)}
<ClassesTable classes={classes} />
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveClassTypeAction from "@/app/lib/classes/saveClassTypeAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
function ClassTypeForm({ classType = {}, onCancel }) {
const router = useRouter();
const [showMessage, setShowMessage] = useState(true);
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveClassTypeAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success && state.redirectTo) {
router.push(state.redirectTo);
}
}, [state?.success, state?.redirectTo, router]);
return (
<div className="w-full">
{state?.message && showMessage && (
<div className="max-w-screen-xl mx-auto w-full">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form
action={action}
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
>
{isPending && <p>Carregando...</p>}
{classType._id && (
<input type="hidden" name="_id" value={classType._id} />
)}
<div className="mb-4">
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Título:
</label>
<input
type="text"
id="title"
name="title"
defaultValue={classType.title || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: Aula de Gramática Avançada"
/>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Descrição:
</label>
<textarea
id="description"
name="description"
defaultValue={classType.description || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
rows="3"
placeholder="Detalhes sobre o conteúdo da aula..."
></textarea>
</div>
<div className="mb-4">
<label htmlFor="ageRange" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Faixa Etária:
</label>
<input
type="text"
id="ageRange"
name="ageRange"
defaultValue={classType.ageRange || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: 8-12 anos, Adultos, etc."
/>
</div>
<div className="mb-6">
<label htmlFor="price" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Preço:
</label>
<input
type="number"
id="price"
name="price"
step="0.01"
defaultValue={classType.price || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: 50.00"
/>
</div>
<div className="flex items-center justify-between">
<button
type="submit"
disabled={isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
<button
type="button"
onClick={onCancel}
className="bg-neutral-500 hover:bg-neutral-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
>
Cancelar
</button>
</div>
</form>
</div>
);
}
export default ClassTypeForm;
@@ -0,0 +1,138 @@
"use client";
import { deleteClassTypeAction } from "@/app/lib/classes/deleteClassType";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import { FaFileCirclePlus } from "react-icons/fa6";
import { LuFileStack } from "react-icons/lu";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function ClassTypesTable({ classTypes }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteClassTypeAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Tipo de Turma
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
Faixa Etária
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left min-w-[120px]">
Preço
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
{classTypes.map((type) => (
<tr
key={type._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 font-medium align-top text-left">
{type.title}
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
{type.ageRange || "-"}
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top whitespace-nowrap">
<Label color="emerald" size="md" className="gap-1">
<span className="text-xs">R$</span>
{type.price?.toFixed(2) || "-"}
</Label>
</td>
<td className="px-4 py-3.5 align-top whitespace-nowrap">
<div className="flex items-center gap-2">
<Link
href={`/admin/dashboard/files/${relatedToTitleUrl(
"classTypes"
)}/${type._id}/add`}
title="Adicionar Arquivo"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaFileCirclePlus className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/${relatedToTitleUrl(
"classTypes"
)}/files/${type._id}`}
title="Ver Arquivos"
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<LuFileStack className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/${relatedToTitleUrl(
"classTypes"
)}/edit/${type._id.toString()}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="_id"
value={type._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar este tipo de turma?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,5 @@
import ClassTypeForm from "../ClassTypeForm";
export default function AddClassTypeForm() {
return <ClassTypeForm />;
}
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AddClassTypeForm from "./AddClassTypeForm";
function AddClassType() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Tipo de Turma" subtitle="Crie um novo tipo de turma" />
<AddClassTypeForm />
</div>
);
}
export default AddClassType;
@@ -0,0 +1,41 @@
import ClassTypeForm from "@/app/(protected)/admin/dashboard/classTypes/ClassTypeForm";
import { getTypeClassAsPlainObject } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditClassTypePage({ params }) {
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const classType = await getTypeClassAsPlainObject(id);
if (!classType) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
classType._id = classType._id.toString();
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Tipo de Turma"
subtitle="Atualize as informações do tipo de turma"
/>
<div className="flex justify-center">
<ClassTypeForm classType={classType} />
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { getClassTypeModel } from "@/app/models/ClassType";
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import Link from "next/link";
import FilesTable from "@/app/(protected)/admin/dashboard/components/FilesTable";
import { getFileModel } from "@/app/models/FilesSchema";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function ClassTypeFilesPage({ params }) {
const { id: classTypeId } = await params;
if (!isValidObjectId(classTypeId)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
await getFileModel();
const classTypeModel = await getClassTypeModel();
try {
const classType = await classTypeModel
.findById(classTypeId)
.populate("files")
.lean();
if (!classType) {
return <div>Class Type not found.</div>;
}
const simplifiedFiles = classType.files.map((file) => ({
...file,
_id: file._id.toString(),
uploadedBy: file.uploadedBy.toString(),
modifiedAt: file.modifiedAt.toString(),
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
category: file.category ? file.category.toString() : null,
url: getFileUrl(file.url), // Converte URL para proxy
}));
return (
<div className="w-full mt-3">
<PageHeader
title={`Arquivos da Classe ${classType.title}`}
subtitle="Gerencie os arquivos associados a este tipo de turma"
actions={
<Link
href={`/admin/dashboard/files/${relatedToTitleUrl(
"classTypes"
)}/${classType._id}/add`}
>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
Adicionar Arquivo
</button>
</Link>
}
/>
<div>
<FilesTable files={simplifiedFiles}></FilesTable>
</div>
</div>
);
} catch (err) {
console.error("Error fetching class type and files:", err);
return <div>Error loading files.</div>;
}
}
export default ClassTypeFilesPage;
@@ -0,0 +1,22 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
export default async function ClassTypesPage() {
return (
<div className="w-full mt-3">
<PageHeader
title="Arquivos de Tipos de Turmas"
subtitle="Gerencie os arquivos associados aos tipos de turmas"
actions={
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Novo Tipo
</button>
</Link>
}
/>
</div>
);
}
@@ -0,0 +1,26 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassTypesTable from "./ClassTypesTable";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
export default async function ClassTypesPage() {
const classTypes = await getAllClassItems();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Tipos de Turmas"
subtitle="Gerencie os tipos de turmas, como 'Starters' etc."
actions={
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Novo Tipo
</button>
</Link>
}
/>
<ClassTypesTable classTypes={classTypes} />
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTransition } from "react";
import { FaEdit, FaTrash, FaDownload } from "react-icons/fa";
import { IoCalendarOutline } from "react-icons/io5";
import { usePathname } from "next/navigation";
import { deleteFile } from "@/app/lib/generalActions/deleteFile";
export default function FilesTable({ files, relatedTo, relatedToId }) {
const [clientFiles, setClientFiles] = useState(files);
const router = useRouter();
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
const [confirmationFile, setConfirmationFile] = useState(null);
const [errorMessage, setErrorMessage] = useState(null);
const onEditFile = (id) => {
router.push(`/admin/dashboard/files/edit/${id.toString()}`);
};
const handleDeleteFile = (file) => {
// Always show confirmation dialog first
setConfirmationFile(file);
};
const handleConfirmDelete = () => {
if (!confirmationFile) return;
startTransition(async () => {
const result = await deleteFile({
_id: confirmationFile._id,
redirectTo: pathname,
relatedTo,
relatedToId,
force: true,
});
if (result.success) {
setClientFiles((prev) => prev.filter((file) => file._id !== confirmationFile._id));
setConfirmationFile(null);
setErrorMessage(null);
} else {
setErrorMessage(result.message || "Erro ao deletar arquivo");
setConfirmationFile(null);
}
});
};
const handleCancelDelete = () => {
setConfirmationFile(null);
setErrorMessage(null);
};
return (
<>
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Título do Arquivo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Tipo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-center">
Tamanho
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell text-center">
Enviado
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{clientFiles &&
clientFiles.map((file) => (
<tr
key={file._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
{file.title}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{file.mimetype || "-"}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300 text-center">
{file.size ? `${(file.size / 1024).toFixed(2)} KB` : "-"}
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300 text-center">
<span className="inline-flex items-center gap-1.5">
<IoCalendarOutline className="text-neutral-400" />
{new Date(file.uploadedAt).toLocaleDateString("pt-BR")}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
{file.url && (
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
title="Download"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaDownload className="text-lg" />
</a>
)}
<button
type="button"
title="Editar"
onClick={() => onEditFile(file._id)}
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</button>
<button
type="button"
title="Excluir"
disabled={isPending}
onClick={() => handleDeleteFile(file)}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<FaTrash className="text-lg" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Error Message */}
{errorMessage && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{errorMessage}</p>
</div>
)}
{/* Confirmation Modal */}
{confirmationFile && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-xl p-6 max-w-md mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
Confirmar Exclusão
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Tem certeza que deseja excluir o arquivo <span className="font-semibold">"{confirmationFile.title}"</span>? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={handleCancelDelete}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-600 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleConfirmDelete}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{isPending ? "Excluindo..." : "Confirmar Exclusão"}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
export default function Error({ error, reset }) {
useEffect(() => {
console.error("Erro no painel administrativo:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
<div className="max-w-md w-full text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 mb-2">
Erro no painel
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-6">
Ocorreu um erro ao carregar os dados do painel administrativo. Tente novamente.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="px-5 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
>
Tentar novamente
</button>
<button
onClick={() => window.location.href = "/admin/dashboard"}
className="px-5 py-2.5 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Painel admin
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import TemplateList from "@/app/(protected)/components/exam-templates/TemplateList";
import TemplateForm from "@/app/(protected)/components/exam-templates/TemplateForm";
export default function ExamTemplatesPage({ initialTemplates }) {
const [templates, setTemplates] = useState(initialTemplates);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
// Callback to update templates from child components
const updateTemplates = useCallback((updater) => {
setTemplates(updater);
}, []);
useEffect(() => {
const handleOpenForm = (e) => {
setEditingTemplate(e.detail);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingTemplate(null);
};
const handleTemplatesUpdate = (e) => {
if (e.detail) {
setTemplates(e.detail);
}
};
window.addEventListener('open-template-form', handleOpenForm);
window.addEventListener('closeTemplateModal', handleCloseForm);
window.addEventListener('templates-updated', handleTemplatesUpdate);
return () => {
window.removeEventListener('open-template-form', handleOpenForm);
window.removeEventListener('closeTemplateModal', handleCloseForm);
window.removeEventListener('templates-updated', handleTemplatesUpdate);
};
}, []);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Templates de Provas</h1>
</div>
<div className="grid gap-6">
<div>
<TemplateList
templates={templates}
setTemplates={updateTemplates}
/>
</div>
<div>
<TemplateForm
isOpen={isFormOpen}
template={editingTemplate}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { getExamTemplates } from '@/app/lib/actions/examActions';
import ExamTemplatesPage from './ExamTemplatesPage';
export default async function Page() {
const result = await getExamTemplates();
const templates = result.success ? result.data : [];
return <ExamTemplatesPage initialTemplates={templates} />;
}
@@ -0,0 +1,56 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import {
AcademicCapIcon,
DocumentDuplicateIcon,
ChartBarIcon,
} from "@heroicons/react/24/outline";
export default function ExamsPage() {
return (
<div className="space-y-6">
<PageHeader title="Gerenciamento de Provas" subtitle="Gerencie as provas, modelos, atribuições e estatísticas" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href="/admin/dashboard/exam-templates"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<AcademicCapIcon className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
<h2 className="text-lg font-semibold">Modelos de Prova</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Crie e gerencie modelos de prova reutilizáveis com questões
</p>
</Link>
<Link
href="/admin/dashboard/assignments"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<DocumentDuplicateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
<h2 className="text-lg font-semibold">Atribuições de Provas</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Atribua modelos de prova às turmas com cronogramas e configurações
</p>
</Link>
<Link
href="/admin/dashboard/statistics/exams"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<ChartBarIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
<h2 className="text-lg font-semibold">Estatísticas de Provas</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Visualize estatísticas agregadas e métricas de desempenho
</p>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
async function AddFileClass({ params }) {
const { relatedToType, relatedToId } = await params;
console.log("Rel type: ", relatedToType)
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
<FileUploadComponent relType={relatedToType} relId={relatedToId} />
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
function AddFileClass() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
<FileUploadComponent />
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,298 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useActionState } from "react";
import { saveFileAction } from "@/app/lib/generalActions/saveFileAction";
import { relatedToTitleUrl, relatedToTitle } from "@/app/lib/helpers/generalUtils";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { getCategoriesAction } from "@/app/lib/categories/getCategoriesAction";
const initialState = {
success: false,
message: null,
inputs: {},
};
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
export default function FileUploadComponent({
relType = null,
relId = null,
file = null,
redirectUrl = null,
}) {
const router = useRouter();
const fileInputRef = useRef(null);
const [state, formAction, isPending] = useActionState(
saveFileAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
const [type] = useState(relType);
const [catId] = useState(relId);
const [editing] = useState(file ? true : false);
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState(editing ? file?.category || "" : "");
// File validation states
const [selectedFile, setSelectedFile] = useState(null);
const [fileError, setFileError] = useState("");
const [fileSize, setFileSize] = useState("");
useEffect(() => {
if (state?.message) {
setShowMessage(true);
// Only redirect/clear on success, keep error message visible
if (state.success) {
const timer = setTimeout(() => {
setShowMessage(false);
if (file) {
const relatedToType = relatedToTitleUrl(file.relatedToType);
router.push(`/admin/dashboard/${relatedToType}/files/${file.relatedToId}`);
} else {
initialState.inputs = {};
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
setSelectedFile(null);
setFileSize("");
setFileError("");
}
}
}, 1500);
return () => clearTimeout(timer);
}
// On error, keep message visible until user submits again
}
}, [state?.message, file, router]);
useEffect(() => {
const fetchCategories = async () => {
const result = await getCategoriesAction();
if (!result.success) {
return;
}
setCategories(result.data || []);
if (editing && file?.category) {
setSelectedCategory(file.category);
}
};
fetchCategories();
}, [editing, file?.category]);
const handleFileChange = (e) => {
const file = e.target.files?.[0];
setFileError("");
setSelectedFile(null);
setFileSize("");
if (!file) return;
// Validate file size
if (file.size > MAX_FILE_SIZE) {
setFileError(`Arquivo muito grande! Máximo permitido: ${formatFileSize(MAX_FILE_SIZE)}`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setSelectedFile(file);
setFileSize(formatFileSize(file.size));
};
return (
<form
action={formAction}
className="bg-white dark:bg-gray-800 p-6 rounded shadow-md max-w-lg mx-auto"
>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Enviar Arquivo{" "}
{relType && relId && <span>para {relatedToTitle(relType)}</span>}
</h2>
{type && catId && (
<>
<input type="hidden" name="relatedToType" value={type} />
<input type="hidden" name="relatedToId" value={catId} />
</>
)}
{editing && (
<>
<input type="hidden" name="id" value={file.id} />
</>
)}
{redirectUrl && (
<input type="hidden" name="redirectUrl" value={redirectUrl} />
)}
{/* Server response message */}
{showMessage && state?.message && (
<div
className={`mb-4 p-3 rounded-lg text-sm font-medium ${
state.success
? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-700"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-700"
}`}
>
<p className="flex items-center gap-2">
{state.success ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
{state.message}
</p>
</div>
)}
{editing && file && (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Arquivo atual: {file.title} (
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Ver
</Link>
)
</p>
)}
{/* File input with validation */}
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Arquivo <span className="text-red-500">*</span>
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-2">
(Máximo: {formatFileSize(MAX_FILE_SIZE)})
</span>
</label>
<input
ref={fileInputRef}
type="file"
name="file"
required={!editing}
onChange={handleFileChange}
disabled={isPending}
className={`w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold ${
fileError
? "file:bg-red-50 file:text-red-700 dark:file:bg-red-900/30 dark:file:text-red-300"
: "file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/30 dark:file:text-indigo-300 hover:file:bg-indigo-100"
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
/>
{/* File info / error */}
{fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{fileError}
</p>
)}
{selectedFile && !fileError && (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
{selectedFile.name} ({fileSize})
</p>
)}
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Título <span className="text-red-500">*</span>:
</label>
<input
type="text"
name="title"
required
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Título do arquivo"
defaultValue={editing ? file.title : state?.inputs?.file}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Descrição:
</label>
<input
type="text"
name="description"
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Descrição opcional..."
defaultValue={editing ? file.description : state?.inputs?.description}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Categoria:
</label>
<select
name="category"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Selecione uma categoria (opcional)</option>
{categories.map((cat) => (
<option key={cat._id} value={cat._id}>
{cat.name}
</option>
))}
</select>
<input
type="text"
name="newCategoryName"
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Ou digite uma nova categoria (opcional)"
defaultValue={state?.inputs?.newCategoryName || ""}
/>
</div>
{/* Submit button with loading state */}
<button
type="submit"
disabled={isPending || !!fileError}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{isPending ? (
<>
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{editing ? "Atualizando..." : "Enviando..."}
</>
) : (
<>
{editing ? "Atualizar" : "Enviar Arquivo"}
</>
)}
</button>
</form>
);
}
@@ -0,0 +1,41 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
import { getItemById } from "@/app/lib/helpers/getItems";
import { getFileModel } from "@/app/models/FilesSchema";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function EditFile({ params }) {
const {id} = await params;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const fileModel = await getFileModel();
const file = await fileModel.findById(id);
const shapedFile = file
? JSON.parse(JSON.stringify({
id: file._id.toString() || id,
title: file.title,
description: file.description,
relatedToType: file.relatedToType,
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
url: file.url,
category: file.category ? file.category.toString() : null
}))
: null;
return (
<div className="flex flex-col w-full">
<PageHeader title="Editar Arquivo" subtitle="Atualize as informações do arquivo" />
<FileUploadComponent file={shapedFile}/>
</div>
);
}
export default EditFile;
@@ -0,0 +1,15 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
function AddFileClass() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" />
{/* <FileUploadComponent /> */}
PÁGINA
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,315 @@
"use client";
import React, { useState } from "react";
import {
XMarkIcon,
CheckCircleIcon,
EyeIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline";
function WhatsAppIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
);
}
const statusLabels = {
new: "Nova",
read: "Lida",
replied: "Respondida",
};
const statusColors = {
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
replied:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
};
const contactLabels = {
email: "Email",
whatsapp: "WhatsApp",
phone: "Telefone",
};
export default function MessageDetailModal({ message, onClose, onStatusChange, onDelete }) {
const [adminNotes, setAdminNotes] = useState(message.adminNotes || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function buildWhatsAppLink() {
let phone = (message.phone || "").replace(/\D/g, "");
if (phone && !phone.startsWith("55")) {
phone = "55" + phone;
}
const text = encodeURIComponent(
`Olá ${message.name}! Recebemos sua mensagem sobre "${message.subject}". `
);
return phone ? `https://wa.me/${phone}?text=${text}` : null;
}
async function handleSaveNotes() {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes }),
});
const data = await res.json();
if (data.success && onStatusChange) {
onStatusChange();
} else if (!data.success) {
setError(data.error || "Erro ao salvar notas.");
}
} catch {
setError("Erro ao salvar notas. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
async function handleUpdateStatus(newStatus) {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
const data = await res.json();
if (data.success && onStatusChange) {
onStatusChange();
onClose();
} else if (!data.success) {
setError(data.error || "Erro ao atualizar status.");
}
} catch {
setError("Erro ao atualizar status. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
async function handleDelete() {
if (!confirm("Deseja realmente excluir esta mensagem?")) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "DELETE",
});
const data = await res.json();
if (data.success && onDelete) {
onDelete();
onClose();
} else if (!data.success) {
setError(data.error || "Erro ao excluir mensagem.");
}
} catch {
setError("Erro ao excluir mensagem. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
const whatsappLink = buildWhatsAppLink();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-neutral-200 dark:border-neutral-700">
<div className="sticky top-0 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700 p-5 flex items-center justify-between rounded-t-2xl">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">
Mensagem
</h2>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[message.status]}`}
>
{statusLabels[message.status]}
</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-5">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Nome
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{message.name}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Contato preferido
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{contactLabels[message.preferredContact] || message.preferredContact}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Email
</p>
<a
href={`mailto:${message.email}`}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline inline-flex items-center gap-1"
>
<EnvelopeIcon className="w-3.5 h-3.5" />
{message.email}
</a>
</div>
{message.phone && (
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Telefone / WhatsApp
</p>
<a
href={`tel:${message.phone}`}
className="text-sm text-neutral-700 dark:text-neutral-300 hover:underline inline-flex items-center gap-1"
>
<PhoneIcon className="w-3.5 h-3.5" />
{message.phone}
</a>
</div>
)}
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Assunto
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{message.subject}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Mensagem
</p>
<div className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-800 rounded-xl p-4 border border-neutral-200 dark:border-neutral-700 leading-relaxed">
{message.message}
</div>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Recebida em
</p>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{formatDate(message.createdAt)}
</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Notas internas
</label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="Adicione notas sobre o atendimento..."
/>
<button
onClick={handleSaveNotes}
disabled={saving}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
<PencilSquareIcon className="w-3.5 h-3.5" />
Salvar notas
</button>
</div>
</div>
<div className="sticky bottom-0 bg-white dark:bg-neutral-900 border-t border-neutral-200 dark:border-neutral-700 p-5 flex flex-col gap-3 rounded-b-2xl">
{error && (
<div className="rounded-xl p-3 text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
{error}
</div>
)}
<div className="flex flex-wrap gap-2">
{whatsappLink && (
<a
href={whatsappLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-green-600 hover:bg-green-700 text-white transition-colors"
>
<WhatsAppIcon className="w-4 h-4" />
Abrir WhatsApp
</a>
)}
<a
href={`mailto:${message.email}`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
>
<EnvelopeIcon className="w-4 h-4" />
Enviar Email
</a>
<div className="flex-1" />
{message.status === "new" && (
<button
onClick={() => handleUpdateStatus("read")}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
<EyeIcon className="w-4 h-4" />
Marcar como lida
</button>
)}
{message.status !== "replied" && (
<button
onClick={() => handleUpdateStatus("replied")}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors disabled:opacity-50"
>
<CheckCircleIcon className="w-4 h-4" />
Marcar respondida
</button>
)}
<button
onClick={handleDelete}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
<TrashIcon className="w-4 h-4" />
Excluir
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
"use client";
import React, { useState, useTransition } from "react";
import {
EyeIcon,
TrashIcon,
MagnifyingGlassIcon,
FunnelIcon,
} from "@heroicons/react/24/outline";
import MessageDetailModal from "./MessageDetailModal";
const statusLabels = {
new: "Nova",
read: "Lida",
replied: "Respondida",
};
const statusColors = {
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
replied:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
};
const contactIcons = {
email: "📧",
whatsapp: "💬",
phone: "📞",
};
export default function MessagesTable({ messages: initialMessages }) {
const [messages, setMessages] = useState(initialMessages);
const [selectedMessage, setSelectedMessage] = useState(null);
const [filterStatus, setFilterStatus] = useState("all");
const [search, setSearch] = useState("");
const [refreshError, setRefreshError] = useState(false);
const [isPending, startTransition] = useTransition();
function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function refreshMessages() {
setRefreshError(false);
startTransition(async () => {
try {
const res = await fetch("/api/contact-messages");
const data = await res.json();
if (data.success) {
setMessages(
data.data.map((m) => ({
...m,
_id: m._id.toString(),
createdAt: m.createdAt,
repliedAt: m.repliedAt,
}))
);
} else {
setRefreshError(true);
}
} catch {
console.error("Erro ao atualizar lista de mensagens.");
setRefreshError(true);
}
});
}
function handleDelete() {
refreshMessages();
}
const filtered = messages.filter((m) => {
const matchesStatus =
filterStatus === "all" || m.status === filterStatus;
const matchesSearch =
!search ||
m.name.toLowerCase().includes(search.toLowerCase()) ||
m.email.toLowerCase().includes(search.toLowerCase()) ||
m.subject.toLowerCase().includes(search.toLowerCase());
return matchesStatus && matchesSearch;
});
const counts = {
all: messages.length,
new: messages.filter((m) => m.status === "new").length,
read: messages.filter((m) => m.status === "read").length,
replied: messages.filter((m) => m.status === "replied").length,
};
return (
<div className="space-y-4">
{refreshError && (
<div className="rounded-xl p-3 text-sm font-medium bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
Não foi possível atualizar a lista de mensagens. Recarregue a página.
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="relative flex-1 max-w-xs">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
<input
type="text"
placeholder="Buscar por nome, email ou assunto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
<div className="flex items-center gap-1.5">
<FunnelIcon className="w-4 h-4 text-neutral-400" />
{["all", "new", "read", "replied"].map((status) => (
<button
key={status}
onClick={() => setFilterStatus(status)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
filterStatus === status
? "bg-indigo-600 text-white"
: "bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700"
}`}
>
{status === "all" ? "Todas" : statusLabels[status]}
<span
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold ${
filterStatus === status
? "bg-white/20 text-white"
: "bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400"
}`}
>
{counts[status]}
</span>
</button>
))}
</div>
</div>
<div className="rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Data
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Nome
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Contato
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Assunto
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Status
</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{filtered.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-neutral-400 dark:text-neutral-500"
>
Nenhuma mensagem encontrada.
</td>
</tr>
) : (
filtered.map((msg) => (
<tr
key={msg._id}
className={`hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${
msg.status === "new"
? "bg-blue-50/50 dark:bg-blue-900/5"
: ""
}`}
>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
{formatDate(msg.createdAt)}
</td>
<td className="px-4 py-3">
<span className="font-medium text-neutral-900 dark:text-white">
{msg.name}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<span className="text-sm">
{contactIcons[msg.preferredContact] || "📧"}
</span>
<span className="text-neutral-600 dark:text-neutral-400 text-xs max-w-[180px] truncate">
{msg.preferredContact === "whatsapp" || msg.preferredContact === "phone"
? msg.phone || msg.email
: msg.email}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-neutral-700 dark:text-neutral-300 max-w-[200px] truncate block">
{msg.subject}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[msg.status]}`}
>
{statusLabels[msg.status]}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<button
onClick={() => setSelectedMessage(msg)}
className="p-1.5 rounded-lg text-neutral-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
title="Ver detalhes"
>
<EyeIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{selectedMessage && (
<MessageDetailModal
message={selectedMessage}
onClose={() => setSelectedMessage(null)}
onStatusChange={refreshMessages}
onDelete={handleDelete}
/>
)}
</div>
);
}
@@ -0,0 +1,33 @@
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import MessagesTable from "./components/MessagesTable";
import { getContactMessageModel } from "@/app/models/ContactMessage";
import { EnvelopeIcon } from "@heroicons/react/24/outline";
export const dynamic = "force-dynamic";
async function MessagesAdminPage() {
const ContactMessage = await getContactMessageModel();
const messages = await ContactMessage.find({})
.sort({ createdAt: -1 })
.lean();
const serialized = messages.map((m) => ({
...m,
_id: m._id.toString(),
createdAt: m.createdAt?.toISOString() || null,
repliedAt: m.repliedAt?.toISOString() || null,
}));
return (
<div className="w-full mt-3">
<PageHeader
title="Mensagens de Contato"
subtitle="Gerencie as mensagens recebidas pelo formulário de contato."
icon={<EnvelopeIcon className="w-6 h-6" />}
/>
<MessagesTable messages={serialized} />
</div>
);
}
export default MessagesAdminPage;
@@ -0,0 +1,24 @@
import AdminDashboard from "@/app/(protected)/components/AdminDashboard";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { auth } from "@/app/lib/utils/auth";
import { redirect } from "next/navigation";
async function AdminDasBoard() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const roles = Array.isArray(session.user.roles) ? session.user.roles : [];
const role = session.user.role;
const isAdmin = roles.includes("admin") || roles.includes("superadmin") || role === "admin" || role === "superadmin";
if (isAdmin) {
return <AdminDashboard />;
} else {
return (
<>
<NotAuthorized />
</>
);
}
}
export default AdminDasBoard;
@@ -0,0 +1,86 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getPaymentModel } from "@/app/models/Payment";
import { getUserModel } from "@/app/models/User";
import { getClassModel } from "@/app/models/Class";
import { auth } from "@/app/lib/utils/auth";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { redirect } from "next/navigation";
import PaymentsByClass from "../components/PaymentsByClass";
function serializePayment(payment) {
return {
_id: payment._id?.toString(),
classId: payment.classId
? {
_id: payment.classId._id?.toString(),
classTitle: payment.classId.classTitle,
}
: null,
userId: payment.userId
? {
_id: payment.userId._id?.toString(),
fullName: payment.userId.fullName,
email: payment.userId.email,
}
: null,
type: payment.type,
amount: payment.amount,
status: payment.status,
paymentDate: payment.paymentDate,
dueDate: payment.dueDate,
description: payment.description,
paymentMethod: payment.paymentMethod,
receiptUrl: payment.receiptUrl,
notes: payment.notes,
createdBy: payment.createdBy?.toString(),
createdAt: payment.createdAt?.toISOString(),
updatedAt: payment.updatedAt?.toISOString(),
relatedObligationId: payment.relatedObligationId?.toString(),
};
}
export default async function AdminPaymentsByClassPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
// Only admins can access this page
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const Payment = await getPaymentModel();
const Class = await getClassModel();
// Fetch all payments and obligations
const payments = await Payment.find({})
.populate("classId", "classTitle")
.populate("userId", "fullName email")
.sort({ createdAt: -1 })
.lean();
// Serialize data for client components
const serializedPayments = payments.map(serializePayment);
return (
<MainSection>
<PageHeader
title="Pagamentos por Turma"
subtitle="Visualização agrupada de obrigações e movimentações financeiras"
actions={
<a
href="/admin/dashboard/payments"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium label-dark hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Voltar para Pagamentos
</a>
}
/>
<PaymentsByClass payments={serializedPayments} />
</MainSection>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { createObligationAction } from "@/app/lib/payments/actions";
export default function CreateObligationForm({ classes, users }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [classStudents, setClassStudents] = useState([]);
const [formData, setFormData] = useState({
classId: "",
userId: "",
amount: "",
dueDate: "",
description: "",
createForAll: false,
});
// Effect to update students when class changes
useEffect(() => {
const selectedClass = classes.find(cls => cls._id === formData.classId);
if (selectedClass && selectedClass.students) {
// Filter users that are in the selected class
const students = users.filter(user =>
selectedClass.students.includes(user._id)
);
setClassStudents(students);
} else {
setClassStudents([]);
}
// Reset userId when class changes
setFormData(prev => ({ ...prev, userId: "" }));
}, [formData.classId, classes, users]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const formDataToSend = new FormData();
formDataToSend.append("classId", formData.classId);
formDataToSend.append("amount", parseFloat(formData.amount));
if (!formData.createForAll) {
formDataToSend.append("userId", formData.userId);
}
if (formData.dueDate) {
formDataToSend.append("dueDate", formData.dueDate);
}
if (formData.description) {
formDataToSend.append("description", formData.description);
}
const result = await createObligationAction(formDataToSend);
if (!result.success) {
throw new Error(result.message || "Erro ao criar obrigação");
}
setSuccess(result.message);
setFormData({
classId: "",
userId: "",
amount: "",
dueDate: "",
description: "",
createForAll: false,
});
router.refresh();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100 mb-4">
Criar Obrigação de Pagamento
</h2>
{error && (
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{success && (
<div className="bg-emerald-600 dark:bg-emerald-900/50 border border-emerald-600 dark:border-emerald-600 text-white dark:text-emerald-200 px-4 py-3 rounded mb-4">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Classe *
</label>
<select
required
value={formData.classId}
onChange={(e) => setFormData({ ...formData, classId: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
>
<option value="">Selecione uma classe</option>
{classes.map((cls) => (
<option key={cls._id} value={cls._id}>
{cls.classTitle}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Aluno (deixe vazio para todos)
</label>
<select
value={formData.userId}
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
disabled={formData.createForAll || !formData.classId}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 disabled:opacity-50"
>
<option value="">Selecione um aluno (opcional)</option>
{classStudents.map((user) => (
<option key={user._id} value={user._id}>
{user.fullName}
</option>
))}
{formData.classId && classStudents.length === 0 && (
<option value="" disabled>
Nenhum aluno encontrado nesta turma
</option>
)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Valor (R$) *
</label>
<input
type="number"
step="0.01"
min="0"
required
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
placeholder="0,00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Data de Vencimento
</label>
<input
type="date"
lang="pt-BR"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Descrição (ex: "Mensalidade Janeiro 2025")
</label>
<input
type="text"
maxLength="200"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
placeholder="Descrição da obrigação"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="createForAll"
checked={formData.createForAll}
onChange={(e) => setFormData({ ...formData, createForAll: e.target.checked, userId: "" })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="createForAll" className="ml-2 block text-sm text-gray-700 dark:text-gray-200 label-dark">
Criar para todos os alunos da turma
</label>
</div>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Criando..." : "Criar Obrigação"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState, useEffect } from "react";
import { updateObligation, deleteObligation } from "@/app/lib/actions/paymentActions";
import { FaEdit, FaTrash, FaSave, FaTimes } from "react-icons/fa";
export default function EditObligationModal({ obligation, isOpen, onClose, onSuccess }) {
const [formData, setFormData] = useState({
amount: "",
description: "",
dueDate: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Update form data when obligation changes
useEffect(() => {
if (obligation) {
setFormData({
amount: obligation.amount?.toString() || "",
description: obligation.description || "",
dueDate: obligation.dueDate
? new Date(obligation.dueDate).toISOString().split("T")[0]
: "",
});
}
}, [obligation]);
if (!isOpen || !obligation) return null;
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const result = await updateObligation(obligation._id, {
amount: parseFloat(formData.amount),
description: formData.description,
dueDate: formData.dueDate || null,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error || "Erro ao atualizar obrigação");
}
} catch (err) {
setError(err.message || "Erro ao atualizar obrigação");
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
setLoading(true);
setError("");
try {
const result = await deleteObligation(obligation._id);
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error || "Erro ao excluir obrigação");
}
} catch (err) {
setError(err.message || "Erro ao excluir obrigação");
} finally {
setLoading(false);
setShowDeleteConfirm(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-800 rounded-xl shadow-xl w-full max-w-md border border-gray-200 dark:border-neutral-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-neutral-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100">
Editar Obrigação
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<FaTimes className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded">
{error}
</div>
)}
{/* Amount */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Valor (R$)
</label>
<input
type="number"
step="0.01"
min="0"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{/* Due Date */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Data de Vencimento
</label>
<input
type="date"
lang="pt-BR"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
rows={3}
maxLength={200}
placeholder="Descrição da obrigação..."
/>
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark mt-1">
{formData.description.length}/200 caracteres
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 border border-red-600 rounded-lg hover:bg-red-700 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800 dark:hover:bg-red-900/30 disabled:opacity-50"
>
<FaTrash className="w-4 h-4" />
Excluir
</button>
<div className="flex-1"></div>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
<FaSave className="w-4 h-4" />
{loading ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-10 rounded-xl">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 m-4 max-w-sm border border-gray-200 dark:border-neutral-700 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900 dark:text-neutral-100 mb-2">
Confirmar Exclusão
</h3>
<p className="text-gray-600 dark:text-gray-200 label-dark mb-4">
Tem certeza que deseja excluir esta obrigação? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={loading}
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleDelete}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
<FaTrash className="w-4 h-4" />
{loading ? "Excluindo..." : "Excluir"}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Label from "@/app/(protected)/components/shared/Label";
import { getPaymentsByObligationAction } from "@/app/lib/payments/actions";
export default function ObligationDetails({ obligations }) {
const [expandedObligation, setExpandedObligation] = useState(null);
const [obligationPayments, setObligationPayments] = useState({});
const [loading, setLoading] = useState(false);
// Group obligations by their reference (description + dueDate)
const groupedObligations = obligations.reduce((acc, obligation) => {
const key = `${obligation.description || "Pagamento"}_${obligation.dueDate || obligation.createdAt}`;
if (!acc[key]) {
acc[key] = {
...obligation,
users: [],
};
}
acc[key].users.push(obligation);
return acc;
}, {});
const groupedList = Object.values(groupedObligations);
const fetchPaymentsForObligation = async (obligation) => {
setLoading(true);
try {
const result = await getPaymentsByObligationAction(obligation._id);
if (result.success) {
setObligationPayments(prev => ({
...prev,
[obligation._id]: result.data || [],
}));
}
} catch (error) {
console.error("Error fetching payments:", error);
} finally {
setLoading(false);
}
};
const handleToggleExpand = (obligation) => {
if (expandedObligation === obligation._id) {
setExpandedObligation(null);
} else {
setExpandedObligation(obligation._id);
if (!obligationPayments[obligation._id]) {
fetchPaymentsForObligation(obligation);
}
}
};
const getStatusBadge = (userObligation) => {
const payments = obligationPayments[userObligation._id] || [];
const hasVerified = payments.some(p => p.status === "verified");
const hasPending = payments.some(p => p.status === "pending_verification");
if (hasVerified) {
return <Label color="emerald" size="sm"> Pago</Label>;
}
if (hasPending) {
return <Label color="blue" size="sm">Aguardando Verificação</Label>;
}
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
};
if (obligations.length === 0) {
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma obrigação de pagamento criada ainda.
</div>
);
}
return (
<div className="space-y-4">
{groupedList.map((group) => (
<div
key={group._id}
className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden"
>
{/* Group Header */}
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-700/50 transition-colors"
onClick={() => handleToggleExpand(group)}
>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{group.description || "Pagamento"}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
Vencimento: {format(new Date(group.dueDate || group.createdAt), "dd/MM/yyyy", { locale: ptBR })}
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{group.users.length} {group.users.length === 1 ? "aluno" : "alunos"}
</p>
<p className="text-lg font-bold text-gray-900 dark:text-neutral-100">
R$ {group.amount?.toFixed(2)}
</p>
</div>
<span className="text-gray-400 dark:text-neutral-500">
{expandedObligation === group._id ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{/* Expanded Details */}
{expandedObligation === group._id && (
<div className="border-t border-gray-200 dark:border-neutral-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Aluno
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Valor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
{group.users.map((userObligation) => (
<tr
key={userObligation._id}
className="hover:bg-gray-50 dark:hover:bg-neutral-700/50"
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{userObligation.userId?.fullName || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{userObligation.userId?.email || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {userObligation.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(userObligation)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,60 @@
export default function PaymentStats({ stats }) {
const statsCards = [
{
title: "Total de Obrigações",
value: stats.totalObligations || 0,
color: "yellow",
icon: "📋",
},
{
title: "Aguardando Pagamento",
value: stats.pendingObligations || 0,
color: "yellow",
icon: "⏳",
},
{
title: "Pagamentos Enviados",
value: stats.pendingPayments || 0,
color: "blue",
icon: "📤",
},
{
title: "Pagamentos Verificados",
value: stats.verifiedPayments || 0,
color: "green",
icon: "✅",
},
{
title: "Pagamentos Rejeitados",
value: stats.rejectedPayments || 0,
color: "red",
icon: "❌",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{statsCards.map((card) => (
<div
key={card.title}
className={`bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 ${
card.color === "yellow" ? "border-amber-500 dark:border-amber-400" :
card.color === "blue" ? "border-blue-500 dark:border-blue-400" :
card.color === "green" ? "border-emerald-500 dark:border-emerald-400" :
"border-red-500 dark:border-red-400"
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark">{card.title}</p>
<p className="text-xl font-bold text-gray-900 dark:text-neutral-100 mt-1">
{card.value}
</p>
</div>
<span className="text-2xl">{card.icon}</span>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,767 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { FaCheck, FaTimes, FaEye, FaEdit, FaList, FaFileInvoiceDollar } from "react-icons/fa";
import EditObligationModal from "./EditObligationModal";
import Label from "@/app/(protected)/components/shared/Label";
import Button from "@/app/(protected)/components/shared/Button";
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
function PaymentStatusBadge({ status, type }) {
const getStatusConfig = (status, type) => {
if (type === "obligation") {
switch (status) {
case "paid":
return { color: "emerald", label: "Paga" };
case "partially_paid":
return { color: "blue", label: "Parcialmente Paga" };
default:
return { color: "amber", label: "Pendente" };
}
}
switch (status) {
case "verified":
return { color: "emerald", label: "Confirmado" };
case "pending_verification":
return { color: "blue", label: "Aguardando Verificação" };
case "rejected":
return { color: "red", label: "Rejeitado" };
default:
return { color: "gray", label: "Pendente" };
}
};
const config = getStatusConfig(status, type);
return (
<Label color={config.color} size="sm">
{config.label}
</Label>
);
}
// ============ OBRIGAÇÕES VIEW ============
function ObligationsCard({ classData, isExpanded, onToggle, obligations, statusFilter, onEditObligation }) {
// Filter obligations based on status
const filteredObligations = useMemo(() => {
if (statusFilter === "all") return obligations;
return obligations.filter(o => {
if (statusFilter === "pending") return o.status === "pending";
if (statusFilter === "partially_paid") return o.status === "partially_paid";
if (statusFilter === "paid") return o.status === "paid";
return true;
});
}, [obligations, statusFilter]);
const pendingCount = obligations.filter(o => o.status === "pending").length;
const partiallyPaidCount = obligations.filter(o => o.status === "partially_paid").length;
const paidCount = obligations.filter(o => o.status === "paid").length;
const totalPendingAmount = obligations
.filter(o => o.status === "pending")
.reduce((sum, o) => sum + (o.remainingAmount || o.amount || 0), 0);
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{classData.classTitle}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{filteredObligations.length} obrigação(ões)
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm">
<span className="block text-amber-600 dark:text-amber-400">
{pendingCount} pendente{totalPendingAmount > 0 && ` (R$ ${totalPendingAmount.toFixed(2)})`}
</span>
<span className="block text-blue-600 dark:text-blue-400">
{partiallyPaidCount} parcial
</span>
<span className="block text-emerald-600 dark:text-emerald-400">
{paidCount} paga
</span>
</div>
<span className="text-gray-400 dark:text-neutral-500 text-lg">
{isExpanded ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-neutral-700">
{filteredObligations.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma obrigação encontrada com o filtro aplicado.
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-neutral-700">
{filteredObligations.map((obligation) => (
<ObligationRow
key={obligation._id}
obligation={obligation}
onEdit={onEditObligation}
/>
))}
</div>
)}
</div>
)}
</div>
);
}
function ObligationRow({ obligation, onEdit }) {
const [showPayments, setShowPayments] = useState(false);
const paidAmount = obligation.payments
?.filter(p => p.status === "verified")
.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
const remainingAmount = (obligation.amount || 0) - paidAmount;
const percentage = obligation.amount > 0 ? (paidAmount / obligation.amount) * 100 : 0;
return (
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-medium text-gray-900 dark:text-neutral-100">
{obligation.userId?.fullName || "N/A"}
</h4>
<PaymentStatusBadge status={obligation.status} type="obligation" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark mb-2">
{obligation.description || "Mensalidade"} Vencimento: {obligation.dueDate ? format(new Date(obligation.dueDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-900 dark:text-neutral-100">
Total: <strong>R$ {obligation.amount?.toFixed(2) || "0.00"}</strong>
</span>
<span className="text-emerald-600 dark:text-emerald-400">
Pago: R$ {paidAmount.toFixed(2)}
</span>
{remainingAmount > 0 && (
<span className="text-amber-600 dark:text-amber-400">
Restante: R$ {remainingAmount.toFixed(2)}
</span>
)}
</div>
{/* Progress bar */}
<div className="mt-2 w-full bg-gray-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-emerald-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
<div className="flex items-center gap-2">
{obligation.payments && obligation.payments.length > 0 && (
<button
onClick={() => setShowPayments(!showPayments)}
className="px-3 py-1 text-sm border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
{showPayments ? "Ocultar" : "Ver"} {obligation.payments.length} { obligation.payments.length > 1 ? "movimentações " : "movimentação"}
</button>
)}
<button
onClick={() => onEdit(obligation)}
title="Editar obrigação"
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<FaEdit className="text-lg" />
</button>
</div>
</div>
{/* Payment History */}
{showPayments && obligation.payments && obligation.payments.length > 0 && (
<div className="mt-4 pl-4 border-l-4 border-emerald-500">
<p className="text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase mb-2">Histórico do Pagamento</p>
<div className="space-y-2">
{obligation.payments.map((payment) => (
<div key={payment._id} className="flex items-center justify-between text-sm p-2 bg-gray-50 dark:bg-neutral-700 rounded">
<div className="flex items-center gap-3">
<PaymentStatusBadge status={payment.status} type="payment" />
<span className="text-gray-900 dark:text-neutral-100">
{payment.paymentDate ? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</span>
<span className="text-gray-500 dark:text-gray-200 label-dark">
{payment.paymentMethod === "pix" ? "PIX" :
payment.paymentMethod === "bank_transfer" ? "Transferência" :
payment.paymentMethod === "cash" ? "Dinheiro" :
payment.paymentMethod}
</span>
</div>
<span className="font-medium text-gray-900 dark:text-neutral-100">
R$ {payment.amount?.toFixed(2) || "0.00"}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ============ HISTÓRICO VIEW ============
function TransactionsCard({ classData, isExpanded, onToggle, transactions, statusFilter, onVerify, onRejectClick, processingId, onViewReceipt }) {
// Filter transactions based on status
const filteredTransactions = useMemo(() => {
if (statusFilter === "all") return transactions;
return transactions.filter(t => {
if (statusFilter === "pending_verification") return t.status === "pending_verification";
if (statusFilter === "verified") return t.status === "verified";
if (statusFilter === "rejected") return t.status === "rejected";
return true;
});
}, [transactions, statusFilter]);
const pendingCount = transactions.filter(t => t.status === "pending_verification").length;
const verifiedCount = transactions.filter(t => t.status === "verified").length;
const rejectedCount = transactions.filter(t => t.status === "rejected").length;
const totalVerified = transactions
.filter(t => t.status === "verified")
.reduce((sum, t) => sum + (t.amount || 0), 0);
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{classData.classTitle}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{filteredTransactions.length} movimentação(ões)
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm">
<span className="block text-blue-600 dark:text-blue-400">
{pendingCount} em verificação
</span>
<span className="block text-emerald-600 dark:text-emerald-400">
{verifiedCount} confirmado
</span>
<span className="block text-red-600 dark:text-red-400">
{rejectedCount} rejeitado
</span>
</div>
<span className="text-gray-400 dark:text-neutral-500 text-lg">
{isExpanded ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-neutral-700">
{filteredTransactions.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma movimentação encontrada com o filtro aplicado.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Data</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Aluno</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Método</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Valor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Ações</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
{filteredTransactions.map((transaction) => (
<tr key={transaction._id} className="hover-card-light">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{transaction.paymentDate ? format(new Date(transaction.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{transaction.userId?.fullName || "N/A"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{transaction.paymentMethod === "pix" ? "PIX" :
transaction.paymentMethod === "bank_transfer" ? "Transferência" :
transaction.paymentMethod === "cash" ? "Dinheiro" :
transaction.paymentMethod === "credit_card" ? "Cartão Crédito" :
transaction.paymentMethod === "debit_card" ? "Cartão Débito" :
transaction.paymentMethod || "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {transaction.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<PaymentStatusBadge status={transaction.status} type="payment" />
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-right">
<div className="flex justify-end gap-2 items-center">
{transaction.receiptUrl ? (
<button
onClick={() => onViewReceipt(transaction.receiptUrl)}
title="Ver comprovante"
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
>
<FaEye className="text-lg" />
</button>
) : (
<span className="text-neutral-400 dark:text-neutral-500 text-xs italic">Sem comp.</span>
)}
{transaction.status === "pending_verification" && (
<>
<button
onClick={() => onVerify(transaction._id, "verified")}
disabled={processingId === transaction._id}
title="Aprovar"
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
<FaCheck className="text-lg" />
</button>
<button
onClick={() => onRejectClick(transaction._id)}
disabled={processingId === transaction._id}
title="Rejeitar"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<FaTimes className="text-lg" />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
export default function PaymentsByClass({ payments, classes }) {
const router = useRouter();
const [viewMode, setViewMode] = useState("obligations"); // "obligations" or "transactions"
const [expandedClasses, setExpandedClasses] = useState({});
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [processingId, setProcessingId] = useState(null);
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
const [editModal, setEditModal] = useState({ open: false, obligation: null });
// Separate obligations and payments, and link them
const { obligationsByClass, transactionsByClass } = useMemo(() => {
const obligations = {};
const transactions = {};
payments.forEach((payment) => {
const classId = payment.classId?._id || "unknown";
if (payment.type === "obligation") {
// Initialize obligation entry if needed
if (!obligations[classId]) {
obligations[classId] = {
classId: payment.classId,
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
obligations: [],
};
}
// Calculate paid amount and status
const relatedPayments = payments.filter(
p => p.type === "payment" && p.relatedObligationId?.toString() === payment._id?.toString()
);
const paidAmount = relatedPayments
.filter(p => p.status === "verified")
.reduce((sum, p) => sum + (p.amount || 0), 0);
const remainingAmount = (payment.amount || 0) - paidAmount;
let status = "pending";
if (paidAmount >= payment.amount) {
status = "paid";
} else if (paidAmount > 0) {
status = "partially_paid";
}
obligations[classId].obligations.push({
...payment,
payments: relatedPayments,
paidAmount,
remainingAmount,
status,
});
} else if (payment.type === "payment") {
// Group transactions by class
if (!transactions[classId]) {
transactions[classId] = {
classId: payment.classId,
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
transactions: [],
};
}
transactions[classId].transactions.push(payment);
}
});
return { obligationsByClass: obligations, transactionsByClass: transactions };
}, [payments]);
// Get current data based on view mode
const currentData = viewMode === "obligations" ? obligationsByClass : transactionsByClass;
// Filter classes based on search and status
const filteredClasses = useMemo(() => {
const classesList = Object.values(currentData);
if (statusFilter === "all") {
return classesList.filter(cls =>
cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return classesList.filter(cls => {
const items = viewMode === "obligations" ? cls.obligations : cls.transactions;
const hasMatchingStatus = items.some(item => {
if (viewMode === "obligations") {
if (statusFilter === "pending") return item.status === "pending";
if (statusFilter === "partially_paid") return item.status === "partially_paid";
if (statusFilter === "paid") return item.status === "paid";
} else {
if (statusFilter === "pending_verification") return item.status === "pending_verification";
if (statusFilter === "verified") return item.status === "verified";
if (statusFilter === "rejected") return item.status === "rejected";
}
return true;
});
return hasMatchingStatus && cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase());
});
}, [currentData, searchTerm, statusFilter, viewMode]);
// Get filter options based on view mode
const filterOptions = viewMode === "obligations"
? [
{ value: "all", label: "Todos os status" },
{ value: "pending", label: "Pendentes" },
{ value: "partially_paid", label: "Parcialmente Pagas" },
{ value: "paid", label: "Pagas" },
]
: [
{ value: "all", label: "Todos os status" },
{ value: "pending_verification", label: "Aguardando Verificação" },
{ value: "verified", label: "Confirmados" },
{ value: "rejected", label: "Rejeitados" },
];
const toggleClass = (classId) => {
setExpandedClasses(prev => ({
...prev,
[classId]: !prev[classId],
}));
};
const expandAll = () => {
const allExpanded = {};
filteredClasses.forEach(cls => {
allExpanded[cls.classId?._id || "unknown"] = true;
});
setExpandedClasses(allExpanded);
};
const collapseAll = () => {
setExpandedClasses({});
};
const handleVerify = async (id, status, notes = "") => {
setProcessingId(id);
try {
const result = await updatePaymentStatusAction(id, { status, notes });
if (result.success) {
router.refresh();
}
} catch (error) {
console.error("Error updating payment:", error);
} finally {
setProcessingId(null);
setRejectModal({ open: false, paymentId: null, notes: "" });
}
};
const handleRejectClick = (paymentId) => {
setRejectModal({ open: true, paymentId, notes: "" });
};
const confirmReject = () => {
if (rejectModal.paymentId) {
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
}
};
const handleEditObligation = (obligation) => {
setEditModal({ open: true, obligation });
};
const handleCloseEditModal = () => {
setEditModal({ open: false, obligation: null });
};
const handleEditSuccess = () => {
router.refresh();
};
if (payments.length === 0) {
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhum pagamento encontrado.
</div>
);
}
// Calculate summary stats
const obligationStats = {
pending: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "pending").length,
partiallyPaid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "partially_paid").length,
paid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "paid").length,
};
const transactionStats = {
pendingVerification: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "pending_verification").length,
verified: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "verified").length,
rejected: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "rejected").length,
};
return (
<div>
{/* View Mode Toggle */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Buscar turma..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
/>
</div>
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-neutral-600">
<button
onClick={() => { setViewMode("obligations"); setStatusFilter("all"); }}
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
viewMode === "obligations"
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
}`}
>
<FaFileInvoiceDollar />
Obrigações
</button>
<button
onClick={() => { setViewMode("transactions"); setStatusFilter("all"); }}
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
viewMode === "transactions"
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
}`}
>
<FaList />
Movimentações
</button>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
>
{filterOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Expandir Todos
</button>
<button
onClick={collapseAll}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Recolher Todos
</button>
</div>
</div>
{/* Summary Stats */}
{viewMode === "obligations" ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pendentes</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.pending}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Parcialmente Pagas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.partiallyPaid}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pagas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.paid}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Obrigações</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
{obligationStats.pending + obligationStats.partiallyPaid + obligationStats.paid}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Aguardando Verificação</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.pendingVerification}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Confirmadas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.verified}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-red-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Rejeitadas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.rejected}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Movimentações</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
{transactionStats.pendingVerification + transactionStats.verified + transactionStats.rejected}
</p>
</div>
</div>
)}
{/* Classes List */}
<div className="space-y-4">
{filteredClasses.length === 0 ? (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma turma encontrada com os filtros aplicados.
</div>
) : (
filteredClasses.map((classData) => (
viewMode === "obligations" ? (
<ObligationsCard
key={classData.classId?._id || "unknown"}
classData={classData}
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
obligations={classData.obligations}
statusFilter={statusFilter}
onEditObligation={handleEditObligation}
/>
) : (
<TransactionsCard
key={classData.classId?._id || "unknown"}
classData={classData}
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
transactions={classData.transactions}
statusFilter={statusFilter}
onVerify={handleVerify}
onRejectClick={handleRejectClick}
processingId={processingId}
onViewReceipt={(url) => setReceiptModal({ open: true, url })}
/>
)
))
)}
</div>
{/* Reject Modal */}
{rejectModal.open && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
Motivo da Rejeição
</h3>
<textarea
value={rejectModal.notes}
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
rows={3}
placeholder="Informe o motivo da rejeição (opcional)"
/>
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
>
Cancelar
</Button>
<Button
variant="danger"
onClick={confirmReject}
disabled={processingId !== null}
>
Confirmar Rejeição
</Button>
</div>
</div>
</div>
)}
{/* Receipt Modal */}
{receiptModal.open && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
>
Fechar
</button>
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
<iframe
src={receiptModal.url}
className="w-full h-[85vh] rounded-lg shadow-xl"
title="Comprovante de pagamento"
/>
) : (
<img
src={receiptModal.url}
alt="Comprovante de pagamento"
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
/>
)}
<div className="mt-4 flex justify-center gap-3">
<a
href={receiptModal.url}
download="comprovante"
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Download
</a>
<Button
variant="secondary"
onClick={() => setReceiptModal({ open: false, url: null })}
>
Fechar
</Button>
</div>
</div>
</div>
)}
{/* Edit Obligation Modal */}
<EditObligationModal
obligation={editModal.obligation}
isOpen={editModal.open}
onClose={handleCloseEditModal}
onSuccess={handleEditSuccess}
/>
</div>
);
}
@@ -0,0 +1,382 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { FaEye } from "react-icons/fa";
import Label from "@/app/(protected)/components/shared/Label";
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
export default function PaymentsTable({ payments }) {
const router = useRouter();
// Debug: log payments with receipt info
payments.forEach(p => {
if (p.type === "payment") {
console.log(`Payment ${p._id}:`, {
type: p.type,
status: p.status,
receiptUrl: p.receiptUrl,
hasReceipt: !!p.receiptUrl,
});
}
});
const [filter, setFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [processingId, setProcessingId] = useState(null);
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
const filteredPayments = payments.filter((payment) => {
if (filter !== "all" && payment.type !== filter) return false;
if (statusFilter !== "all" && payment.status !== statusFilter) return false;
return true;
});
const getStatusBadge = (payment) => {
if (payment.type === "obligation") {
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
}
const statusConfig = {
pending_verification: { color: "blue", label: "Aguardando Verificação" },
verified: { color: "emerald", label: "Pago" },
rejected: { color: "red", label: "Rejeitado" },
};
const config = statusConfig[payment.status];
if (!config) return <Label color="gray" size="sm">{payment.status}</Label>;
return <Label color={config.color} size="sm">{config.label}</Label>;
};
const handleVerify = async (id, status, notes = "") => {
setProcessingId(id);
try {
const result = await updatePaymentStatusAction(id, { status, notes });
if (result.success) {
router.refresh();
}
} catch (error) {
console.error("Error updating payment:", error);
} finally {
setProcessingId(null);
setRejectModal({ open: false, paymentId: null, notes: "" });
}
};
const handleRejectClick = (paymentId) => {
setRejectModal({ open: true, paymentId, notes: "" });
};
const confirmReject = () => {
if (rejectModal.paymentId) {
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
}
};
const getTypeLabel = (type) => {
return type === "obligation" ? "Obrigação" : "Pagamento";
};
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 flex gap-2 flex-wrap bg-neutral-50 dark:bg-neutral-800/50">
<button
onClick={() => {
setFilter("all");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "all" && statusFilter === "all"
? "bg-blue-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Todos
</button>
<button
onClick={() => {
setFilter("obligation");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "obligation"
? "bg-amber-500 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Obrigações
</button>
<button
onClick={() => {
setFilter("payment");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "payment"
? "bg-emerald-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Pagamentos
</button>
{filter === "payment" && (
<div className="flex gap-2 ml-auto">
<button
onClick={() => setStatusFilter("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "all"
? "bg-blue-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Todos Status
</button>
<button
onClick={() => setStatusFilter("pending_verification")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "pending_verification"
? "bg-blue-500 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Pendentes
</button>
<button
onClick={() => setStatusFilter("verified")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "verified"
? "bg-emerald-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Verificados
</button>
<button
onClick={() => setStatusFilter("rejected")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "rejected"
? "bg-red-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Rejeitados
</button>
</div>
)}
</div>
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-gray-200 label-dark font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Tipo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Aluno
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Grupo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Pagador
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Valor
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Data
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Status
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{filteredPayments.map((payment) => (
<tr
key={payment._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<Label
color={
payment.type === "obligation"
? "amber"
: payment.status === "rejected"
? "red"
: "emerald"
}
size="sm"
>
{payment.type === "obligation" ? "Obrigação" : payment.status === "rejected" ? "Pagamento Rejeitado" : "Pagamento"}
</Label>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.userId?.fullName || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.classId?.classTitle || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.payerName || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {payment.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-200 label-dark">
{payment.paymentDate
? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR })
: payment.dueDate
? `Venc: ${format(new Date(payment.dueDate), "dd/MM/yyyy", { locale: ptBR })}`
: "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(payment)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
{payment.type === "payment" ? (
<div className="flex justify-end gap-2 items-center">
{payment.receiptUrl ? (
<button
onClick={() => setReceiptModal({ open: true, url: payment.receiptUrl })}
title="Ver comprovante"
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
>
<FaEye className="text-lg" />
</button>
) : (
<span
title="Sem comprovante anexado"
className="text-neutral-400 dark:text-neutral-500 text-xs italic"
>
Sem comp.
</span>
)}
{payment.status === "pending_verification" && (
<>
<button
onClick={() => handleVerify(payment._id, "verified")}
disabled={processingId === payment._id}
title="Aprovar"
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
<span className="text-lg font-bold"></span>
</button>
<button
onClick={() => handleRejectClick(payment._id)}
disabled={processingId === payment._id}
title="Rejeitar"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<span className="text-lg font-bold"></span>
</button>
</>
)}
{payment.status === "verified" && (
<Label color="emerald" size="sm"> Verificado</Label>
)}
{payment.status === "rejected" && (
<Label color="red" size="sm"> Rejeitado</Label>
)}
</div>
) : (
<span className="text-neutral-700 dark:text-gray-200 label-dark text-xs italic">
Aguardando pagamento
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredPayments.length === 0 && (
<div className="text-center py-8 text-gray-700 dark:text-gray-200 label-dark">
Nenhum registro encontrado
</div>
)}
{rejectModal.open && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
Motivo da Rejeição
</h3>
<textarea
value={rejectModal.notes}
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
rows={3}
placeholder="Informe o motivo da rejeição (opcional)"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Cancelar
</button>
<button
onClick={confirmReject}
disabled={processingId !== null}
className="px-4 py-2 rounded-lg bg-red-600 dark:bg-red-900/50 text-white dark:text-red-200 text-sm font-medium hover:bg-red-700 dark:hover:bg-red-900/70 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmar Rejeição
</button>
</div>
</div>
</div>
)}
{/* Receipt Modal */}
{receiptModal.open && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
>
Fechar
</button>
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
<iframe
src={receiptModal.url}
className="w-full h-[85vh] rounded-lg shadow-xl"
title="Comprovante de pagamento"
/>
) : (
<img
src={receiptModal.url}
alt="Comprovante de pagamento"
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
/>
)}
<div className="mt-4 flex justify-center gap-3">
<a
href={receiptModal.url}
download="comprovante"
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Download
</a>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getPaymentModel } from "@/app/models/Payment";
import { getUserModel } from "@/app/models/User";
import { getClassModel } from "@/app/models/Class";
import { auth } from "@/app/lib/utils/auth";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { redirect } from "next/navigation";
import PaymentStats from "./components/PaymentStats";
import CreateObligationForm from "./components/CreateObligationForm";
function serializePayment(payment) {
return {
_id: payment._id?.toString(),
classId: payment.classId
? {
_id: payment.classId._id?.toString(),
classTitle: payment.classId.classTitle,
}
: null,
userId: payment.userId
? {
_id: payment.userId._id?.toString(),
fullName: payment.userId.fullName,
email: payment.userId.email,
}
: null,
type: payment.type,
amount: payment.amount,
status: payment.status,
paymentDate: payment.paymentDate,
dueDate: payment.dueDate,
description: payment.description,
paymentMethod: payment.paymentMethod,
receiptUrl: payment.receiptUrl,
notes: payment.notes,
createdBy: payment.createdBy?.toString(),
createdAt: payment.createdAt?.toISOString(),
updatedAt: payment.updatedAt?.toISOString(),
relatedObligationId: payment.relatedObligationId?.toString(),
};
}
function serializeClass(cls) {
return {
_id: cls._id?.toString(),
classTitle: cls.classTitle,
students: cls.students?.map(student => student._id?.toString()) || [],
};
}
function serializeUser(user) {
return {
_id: user._id?.toString(),
fullName: user.fullName,
email: user.email,
};
}
export default async function AdminPaymentsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
// Only admins can access this page
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const Payment = await getPaymentModel();
const Class = await getClassModel();
// Fetch all payments and obligations
const payments = await Payment.find({})
.populate("classId", "classTitle")
.populate("userId", "fullName email")
.sort({ createdAt: -1 })
.lean();
// Fetch classes with their students for the obligation creation form
const classes = await Class.find({})
.populate("students", "fullName email _id")
.lean();
// Get all students for the user selection dropdown
const allUsers = await UserModel.find({
$or: [
{ roles: "student" },
{ roles: "guardian" }
]
}).select("fullName email _id").lean();
// Serialize data for client components
const serializedPayments = payments.map(serializePayment);
const serializedClasses = classes.map(serializeClass);
const serializedUsers = allUsers.map(serializeUser);
// Separate obligations and payments
const obligations = serializedPayments.filter(p => p.type === "obligation");
const userPayments = serializedPayments.filter(p => p.type === "payment");
// Calculate stats
const totalObligations = obligations.length;
const pendingObligations = obligations.filter(o => o.status === "pending").length;
const pendingPayments = userPayments.filter(p => p.status === "pending_verification").length;
const verifiedPayments = userPayments.filter(p => p.status === "verified").length;
const rejectedPayments = userPayments.filter(p => p.status === "rejected").length;
const stats = {
totalObligations,
pendingObligations,
pendingPayments,
verifiedPayments,
rejectedPayments,
};
return (
<MainSection>
<PageHeader
title="Pagamentos"
subtitle="Gerenciamento de obrigações e pagamentos"
actions={
<a
href="/admin/dashboard/payments/by-class"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3v18h18"/>
<path d="m19 9-5 5-4-4-3 3"/>
</svg>
Ver Pagamentos por Turma
</a>
}
/>
<PaymentStats stats={stats} />
<div className="mb-8">
<CreateObligationForm
classes={serializedClasses}
users={serializedUsers}
/>
</div>
</MainSection>
);
}
@@ -0,0 +1,396 @@
"use client";
import { useState, useEffect } from "react";
import { updateOrderStatusAction } from "@/app/lib/orders/actions";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
import ProductImage from "@/app/(protected)/components/shared/ProductImage";
const STATUS_CONFIG = {
pending: { label: "Pendente", color: "gray" },
pending_verification: { label: "Aguardando Verificação", color: "amber" },
approved: { label: "Aprovado", color: "emerald" },
rejected: { label: "Rejeitado", color: "red" },
cancelled: { label: "Cancelado", color: "red" },
};
const formatPrice = (price) =>
new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(price);
const formatDate = (date) => {
if (!date) return "-";
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
};
export default function OrdersTable({ orders = [], stats = {} }) {
const [statusFilter, setStatusFilter] = useState("all");
const [productFilter, setProductFilter] = useState("all");
const [actionState, setActionState] = useState({ loading: false, message: null, type: null });
const [selectedOrder, setSelectedOrder] = useState(null);
const [rejectionReason, setRejectionReason] = useState("");
const productIds = [...new Set(orders.map((o) => o.productId?._id).filter(Boolean))];
const productNames = {};
orders.forEach((o) => {
if (o.productId?._id && o.productId?.title) {
productNames[o.productId._id] = o.productId.title;
}
});
let filteredOrders = orders;
if (statusFilter !== "all") {
filteredOrders = filteredOrders.filter((o) => o.status === statusFilter);
}
if (productFilter !== "all") {
filteredOrders = filteredOrders.filter((o) => o.productId?._id === productFilter);
}
const handleAction = async (orderId, status) => {
setActionState({ loading: true, message: null, type: null });
const result = await updateOrderStatusAction(orderId, {
status,
rejectionReason: status === "rejected" ? rejectionReason : undefined,
});
setActionState({
loading: false,
message: result.message,
type: result.success ? "success" : "error",
});
if (result.success) {
setSelectedOrder(null);
setRejectionReason("");
}
};
useEffect(() => {
if (actionState.message) {
const timer = setTimeout(() => setActionState((s) => ({ ...s, message: null })), 5000);
return () => clearTimeout(timer);
}
}, [actionState.message]);
return (
<div className="mt-6 space-y-4">
{actionState.message && (
<FlashMessage message={actionState.message} type={actionState.type} />
)}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => setStatusFilter(statusFilter === "all" ? null : "all")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "all"
? "bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{stats.total || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Total</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "pending" ? "all" : "pending")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "pending"
? "bg-gray-50 dark:bg-gray-900/20 border-2 border-gray-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-gray-600 dark:text-gray-400">{stats.pendingNoProof || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Sem Comprovante</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "pending_verification" ? "all" : "pending_verification")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "pending_verification"
? "bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pending || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Pendentes</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "approved" ? "all" : "approved")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "approved"
? "bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.approved || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Aprovados</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "rejected" ? "all" : "rejected")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "rejected"
? "bg-red-50 dark:bg-red-900/20 border-2 border-red-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Rejeitados</p>
</button>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-neutral-500 dark:text-neutral-400">Filtrar por produto:</label>
<select
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-700 dark:text-neutral-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
>
<option value="all">Todos os produtos</option>
{productIds.map((id) => (
<option key={id} value={id}>
{productNames[id] || "Produto removido"}
</option>
))}
</select>
{(statusFilter !== "all" || productFilter !== "all") && (
<button
onClick={() => { setStatusFilter("all"); setProductFilter("all"); }}
className="text-xs text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer"
>
Limpar filtros
</button>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Produto
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Aluno
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-right">
Valor
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Status
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Data
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
Nenhum pedido encontrado.
</td>
</tr>
) : (
filteredOrders.map((order) => {
const statusCfg = STATUS_CONFIG[order.status] || { label: order.status, color: "gray" };
return (
<tr
key={order._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-left">
<div className="flex items-center gap-3">
<ProductImage
src={getFileUrl(order.productId.imageUrl)}
alt={order.productId?.title}
className="w-10 h-10 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
size="sm"
/>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{order.productId?.title || "Produto removido"}
</p>
</div>
</div>
</td>
<td className="px-6 py-3.5 text-neutral-700 dark:text-neutral-300 text-left">
<p className="font-medium">{order.userId?.fullName || "-"}</p>
<p className="text-xs text-neutral-500">{order.userId?.email || ""}</p>
</td>
<td className="px-6 py-3.5 text-right font-semibold text-neutral-900 dark:text-neutral-100">
{formatPrice(order.amount)}
</td>
<td className="px-6 py-3.5 text-center">
<Label color={statusCfg.color} size="sm">
{statusCfg.label}
</Label>
</td>
<td className="px-6 py-3.5 text-center text-neutral-500 dark:text-neutral-400 text-xs">
{formatDate(order.createdAt)}
</td>
<td className="px-4 py-3.5 text-center whitespace-nowrap">
<button
onClick={() => setSelectedOrder(order)}
className="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer"
>
Ver
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{selectedOrder && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Detalhes do Pedido
</h3>
<button
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 pb-4 border-b border-neutral-200 dark:border-neutral-700">
<ProductImage
src={getFileUrl(selectedOrder.productId.imageUrl)}
alt={selectedOrder.productId.title}
className="w-16 h-16 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
/>
<div>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">
{selectedOrder.productId?.title || "Produto removido"}
</p>
<p className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
{formatPrice(selectedOrder.amount)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-neutral-500 dark:text-neutral-400">Aluno</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.userId?.fullName}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Status</p>
<Label color={(STATUS_CONFIG[selectedOrder.status] || { color: "gray" }).color} size="sm">
{(STATUS_CONFIG[selectedOrder.status] || { label: selectedOrder.status }).label}
</Label>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Método</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.paymentMethod || "-"}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Data do Pagamento</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{formatDate(selectedOrder.paymentDate)}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Data do Pedido</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{formatDate(selectedOrder.createdAt)}
</p>
</div>
{selectedOrder.payerName && (
<div>
<p className="text-neutral-500 dark:text-neutral-400">Quem pagou</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.payerName}
</p>
</div>
)}
</div>
{selectedOrder.receiptUrl && (
<div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">Comprovante</p>
<img
src={selectedOrder.receiptUrl}
alt="Comprovante"
className="max-w-full rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
</div>
)}
{selectedOrder.rejectionReason && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-700 dark:text-red-300">
<strong>Motivo da rejeição:</strong> {selectedOrder.rejectionReason}
</p>
</div>
)}
{selectedOrder.status === "pending_verification" && (
<>
<div>
<label className="block text-sm text-neutral-500 dark:text-neutral-400 mb-1">
Motivo da rejeição (se aplicável)
</label>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
rows="2"
placeholder="Informe o motivo caso vá rejeitar..."
></textarea>
</div>
<div className="flex gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={() => handleAction(selectedOrder._id, "approved")}
disabled={actionState.loading}
className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
>
Aprovar
</button>
<button
onClick={() => handleAction(selectedOrder._id, "rejected")}
disabled={actionState.loading}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
>
Rejeitar
</button>
</div>
</>
)}
</div>
<div className="flex justify-end mt-4">
<button
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
Fechar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import OrdersTable from "./OrdersTable";
import { getOrdersAction } from "@/app/lib/orders/actions";
import { auth } from "@/app/lib/utils/auth";
import { getUserModel } from "@/app/models/User";
import { redirect } from "next/navigation";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
export default async function AdminOrdersPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const result = await getOrdersAction({});
const orders = result.success ? result.data : [];
const pending = orders.filter((o) => o.status === "pending_verification").length;
const pendingNoProof = orders.filter((o) => o.status === "pending").length;
const approved = orders.filter((o) => o.status === "approved").length;
const rejected = orders.filter((o) => o.status === "rejected").length;
const total = orders.length;
const stats = { total, pending, pendingNoProof, approved, rejected };
return (
<MainSection>
<PageHeader
title="Pedidos"
subtitle="Gerencie pedidos de compra dos alunos"
/>
<OrdersTable orders={orders} stats={stats} />
</MainSection>
);
}
@@ -0,0 +1,508 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useActionState } from "react";
import { saveProductAction } from "@/app/lib/products/actions";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
function ProductForm({ product = {}, classTypes = [], classes = [], onCancel }) {
const router = useRouter();
const fileInputRef = useRef(null);
const formRef = useRef(null);
const MAX_FILE_SIZE = 500 * 1024 * 1024;
const [showMessage, setShowMessage] = useState(true);
const [saleType, setSaleType] = useState(product.saleType || "direct");
const [isFree, setIsFree] = useState(product.price === 0 && product.saleType === "direct");
const [imageInputType, setImageInputType] = useState(
product.imageUrl?.startsWith("http") ? "url" : "upload"
);
const [imagePreview, setImagePreview] = useState(product.imageUrl ? getFileUrl(product.imageUrl) : "");
const [uploadedFileName, setUploadedFileName] = useState("");
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(saveProductAction, initialState);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
useEffect(() => {
if (state?.success) {
router.push("/admin/dashboard/produtos");
}
}, [state?.success, router]);
const handleFileChange = (e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
alert(`Arquivo "${file.name}" (${sizeMB}MB) excede o limite de 500MB.`);
e.target.value = "";
return;
}
setUploadedFileName(file.name);
const reader = new FileReader();
reader.onloadend = () => setImagePreview(reader.result);
reader.readAsDataURL(file);
}
};
const clearImage = () => {
setImagePreview("");
setUploadedFileName("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = (e) => {
const formData = new FormData(formRef.current);
const productFiles = formData.getAll("productFiles");
for (const file of productFiles) {
if (file && file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
alert(`Arquivo "${file.name}" (${sizeMB}MB) excede o limite de 500MB.`);
e.preventDefault();
return;
}
}
};
return (
<div className="w-full max-w-2xl mx-auto">
{state?.message && showMessage && (
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
)}
<form ref={formRef} action={action} onSubmit={handleSubmit} className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md">
{isPending && <p className="text-center mb-4">Salvando...</p>}
{product._id && <input type="hidden" name="_id" value={product._id} />}
<input type="hidden" name="saleType" value={saleType} />
<div className="space-y-6">
{/* Sale Type Toggle */}
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-3">
Tipo de Venda <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setSaleType("direct")}
className={`p-4 rounded-xl border-2 transition-all text-left cursor-pointer ${
saleType === "direct"
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20"
: "border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
saleType === "direct"
? "bg-indigo-600 text-white"
: "bg-neutral-100 dark:bg-neutral-700 text-neutral-500"
}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
</svg>
</div>
<div>
<p className={`font-semibold text-sm ${saleType === "direct" ? "text-indigo-700 dark:text-indigo-300" : "text-neutral-700 dark:text-neutral-300"}`}>
Venda Direta
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Compra no app, pagamento verificado
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => setSaleType("affiliate")}
className={`p-4 rounded-xl border-2 transition-all text-left cursor-pointer ${
saleType === "affiliate"
? "border-amber-500 bg-amber-50 dark:bg-amber-900/20"
: "border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
saleType === "affiliate"
? "bg-amber-600 text-white"
: "bg-neutral-100 dark:bg-neutral-700 text-neutral-500"
}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<p className={`font-semibold text-sm ${saleType === "affiliate" ? "text-amber-700 dark:text-amber-300" : "text-neutral-700 dark:text-neutral-300"}`}>
Afiliado
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Link externo (Amazon, etc.)
</p>
</div>
</div>
</button>
</div>
</div>
<div>
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Título <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
defaultValue={product.title || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Ex: Material Complementar de Gramática"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Descrição
</label>
<textarea
id="description"
name="description"
defaultValue={product.description || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
rows="3"
placeholder="Descrição do produto..."
></textarea>
</div>
{/* Image */}
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Imagem do Produto
</label>
<div className="flex gap-4 mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="upload"
checked={imageInputType === "upload"}
onChange={() => setImageInputType("upload")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Fazer upload</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="url"
checked={imageInputType === "url"}
onChange={() => setImageInputType("url")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Usar URL</span>
</label>
</div>
{imageInputType === "upload" && (
<div>
<input
ref={fileInputRef}
type="file"
id="imageFile"
name="imageFile"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
/>
{uploadedFileName && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Arquivo: {uploadedFileName}
</p>
)}
</div>
)}
{imageInputType === "url" && (
<input
type="url"
id="imageUrl"
name="imageUrl"
defaultValue={product.imageUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://example.com/image.jpg"
/>
)}
{imagePreview && (
<div className="mt-3 relative inline-block">
<img
src={imagePreview}
alt="Preview"
className="w-32 h-32 object-cover rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
title="Remover imagem"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Affiliate-specific fields */}
{saleType === "affiliate" && (
<div>
<label htmlFor="affiliateUrl" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Link de Afiliado <span className="text-red-500">*</span>
</label>
<input
type="url"
id="affiliateUrl"
name="affiliateUrl"
defaultValue={product.affiliateUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://amazon.com.br/..."
required
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
O aluno será redirecionado para este link ao clicar
</p>
</div>
)}
{/* Price - visible for both, required for direct */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{saleType === "direct" && (
<div className="flex items-center gap-2 mb-2">
<label htmlFor="price" className="text-neutral-700 dark:text-neutral-200 text-sm font-bold">
Preço (R$) {!isFree && <span className="text-red-500">*</span>}
</label>
<button
type="button"
onClick={() => setIsFree(!isFree)}
className={`ml-auto text-xs px-2.5 py-1 rounded-full border transition-colors cursor-pointer ${
isFree
? "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
: "border-neutral-300 dark:border-neutral-600 text-neutral-500 dark:text-neutral-400 hover:border-neutral-400"
}`}
>
{isFree ? "Gratuito" : "Marcar como gratuito"}
</button>
</div>
)}
{saleType === "affiliate" && (
<label htmlFor="price" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Preço (R$)
</label>
)}
<input
type="number"
id="price"
name="price"
defaultValue={product.price ?? ""}
step="0.01"
min="0"
disabled={isFree}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={isFree ? "Gratuito" : "49.90"}
required={saleType === "direct" && !isFree}
/>
{isFree && <input type="hidden" name="price" value="0" />}
{saleType === "affiliate" && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Preço de referência (apenas exibição)
</p>
)}
{isFree && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
Produto gratuito acesso será liberado imediatamente após "compra"
</p>
)}
</div>
{/* Type - only for direct */}
{saleType === "direct" && (
<div>
<label htmlFor="type" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Categoria
</label>
<select
id="type"
name="type"
defaultValue={product.type || "digital"}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
>
<option value="digital">Digital</option>
<option value="physical">Físico</option>
<option value="course_access">Acesso a Curso</option>
</select>
</div>
)}
</div>
{/* Stock & Active - only relevant for direct */}
{saleType === "direct" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="stock" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Estoque (vazio = ilimitado)
</label>
<input
type="number"
id="stock"
name="stock"
defaultValue={product.stock ?? ""}
min="0"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Deixe vazio para ilimitado"
/>
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="active"
name="active"
value="true"
defaultChecked={product.active !== undefined ? product.active : true}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label htmlFor="active" className="text-neutral-700 dark:text-neutral-200 text-sm font-medium">
Produto ativo
</label>
</div>
</div>
)}
{saleType === "affiliate" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="active"
name="active"
value="true"
defaultChecked={product.active !== undefined ? product.active : true}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label htmlFor="active" className="text-neutral-700 dark:text-neutral-200 text-sm font-medium">
Produto ativo
</label>
</div>
)}
{/* Class linking - only for direct */}
{saleType === "direct" && classes.length > 0 && (
<div>
<label htmlFor="relatedClass" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Turma Vinculada (opcional)
</label>
<select
id="relatedClass"
name="relatedClass"
defaultValue={product.relatedClass || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
>
<option value="">Nenhuma</option>
{classes.map((cls) => (
<option key={cls._id} value={cls._id}>
{cls.classTitle}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Ao aprovar a compra, o usuário será matriculado nesta turma automaticamente
</p>
</div>
)}
{saleType === "direct" && classTypes.length > 0 && (
<div>
<label htmlFor="relatedClassType" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Tipo de Turma Vinculado (opcional)
</label>
<select
id="relatedClassType"
name="relatedClassType"
defaultValue={product.relatedClassType || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
>
<option value="">Nenhum</option>
{classTypes.map((ct) => (
<option key={ct._id} value={ct._id}>
{ct.title}
</option>
))}
</select>
</div>
)}
</div>
{saleType === "direct" && (
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Arquivos do Produto
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
Estes arquivos serão liberados ao comprador após aprovação do pagamento
</p>
<input
type="file"
name="productFiles"
multiple
className="w-full text-sm text-neutral-700 dark:text-neutral-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 dark:file:bg-indigo-900/20 file:text-indigo-700 dark:file:text-indigo-300 hover:file:bg-indigo-100"
/>
{product.files && product.files.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Arquivos anexados ({product.files.length}):
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">
Para remover arquivos, use a gestão de arquivos do sistema.
</p>
</div>
)}
</div>
)}
<div className="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<button
type="button"
onClick={onCancel || (() => router.push("/admin/dashboard/produtos"))}
className="px-4 py-2 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
</div>
);
}
export default ProductForm;
@@ -0,0 +1,171 @@
"use client";
import { deleteProductAction } from "@/app/lib/products/actions";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
import ProductImage from "@/app/(protected)/components/shared/ProductImage";
const TYPE_LABELS = {
digital: "Digital",
physical: "Físico",
course_access: "Acesso a Curso",
};
const SALE_TYPE_LABELS = {
direct: { label: "Venda Direta", color: "indigo" },
affiliate: { label: "Afiliado", color: "amber" },
};
const formatPrice = (price) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(price);
};
export default function ProductsTable({ products = [] }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(deleteProductAction, initialState);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Produto
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Tipo
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-right">
Preço
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-center">
Estoque
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Status
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
{products.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
Nenhum produto cadastrado ainda.
</td>
</tr>
) : (
products.map((product) => (
<tr
key={product._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 align-top text-left">
<div className="flex items-center gap-3">
<ProductImage
src={getFileUrl(product.imageUrl)}
alt={product.title}
className="w-12 h-12 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
size="sm"
/>
<div>
<p className="font-medium">{product.title}</p>
{product.description && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 line-clamp-1">
{product.description}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-3.5 text-neutral-700 dark:text-neutral-300 align-top">
{(() => {
const st = SALE_TYPE_LABELS[product.saleType] || SALE_TYPE_LABELS.direct;
return <Label color={st.color} size="sm">{st.label}</Label>;
})()}
</td>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 align-top text-right font-semibold">
{formatPrice(product.price)}
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-center align-top text-neutral-700 dark:text-neutral-300">
{product.stock === null ? (
<Label color="blue" size="sm">Ilimitado</Label>
) : (
<span>{product.stock}</span>
)}
</td>
<td className="px-6 py-3.5 text-center align-top">
{product.active ? (
<Label color="emerald" size="sm">Ativo</Label>
) : (
<Label color="red" size="sm">Inativo</Label>
)}
</td>
<td className="px-4 py-3.5 align-top whitespace-nowrap">
<div className="flex items-center gap-2">
<Link
href={`/admin/dashboard/produtos/edit/${product._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input type="hidden" name="_id" value={product._id} />
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm("Tem certeza que deseja excluir este produto?")) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,27 @@
import ProductForm from "../ProductForm";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { getClassModel } from "@/app/models/Class";
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
export default async function AddProductPage() {
const classTypes = await getAllClassItems();
const ClassModel = await getClassModel();
const classesRaw = await ClassModel.find({}).select("classTitle").lean();
const classes = classesRaw.map((c) => ({
_id: c._id.toString(),
classTitle: c.classTitle,
}));
return (
<MainSection>
<PageHeader
title="Adicionar Produto"
subtitle="Crie um novo produto para venda direta"
/>
<div className="mt-6">
<ProductForm classTypes={classTypes} classes={classes} />
</div>
</MainSection>
);
}
@@ -0,0 +1,49 @@
import ProductForm from "../../ProductForm";
import { getProductByIdAction } from "@/app/lib/products/actions";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { getClassModel } from "@/app/models/Class";
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { notFound } from "next/navigation";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditProductPage({ params }) {
const { id } = await params;
if (!isValidObjectId(id)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
</MainSection>
);
}
const result = await getProductByIdAction(id);
if (!result.success || !result.data) {
notFound();
}
const product = result.data;
const classTypes = await getAllClassItems();
const ClassModel = await getClassModel();
const classesRaw = await ClassModel.find({}).select("classTitle").lean();
const classes = classesRaw.map((c) => ({
_id: c._id.toString(),
classTitle: c.classTitle,
}));
return (
<MainSection>
<PageHeader
title="Editar Produto"
subtitle={`Editando: ${product.title}`}
/>
<div className="mt-6">
<ProductForm product={product} classTypes={classTypes} classes={classes} />
</div>
</MainSection>
);
}
@@ -0,0 +1,41 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ProductsTable from "./ProductsTable";
import { getProductsAction } from "@/app/lib/products/actions";
import { auth } from "@/app/lib/utils/auth";
import { getUserModel } from "@/app/models/User";
import { redirect } from "next/navigation";
import MainSection from "@/app/(protected)/components/shared/Main";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
export default async function AdminProductsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const result = await getProductsAction({ active: undefined });
const products = result.success ? result.data : [];
return (
<MainSection>
<PageHeader
title="Gerenciar Produtos"
subtitle="Gerencie os produtos disponíveis para compra direta"
actions={
<Link href="/admin/dashboard/produtos/add">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Produto
</button>
</Link>
}
/>
<ProductsTable products={products} />
</MainSection>
);
}
@@ -0,0 +1,32 @@
import { getExamStatistics } from '@/app/lib/actions/examActions';
import ExamStatistics from '@/app/(protected)/components/teacher/ExamStatistics';
import PageHeader from '@/app/(protected)/components/shared/PageHeader';
import { ChartBarIcon } from '@heroicons/react/24/outline';
export default async function ExamStatisticsPage({
searchParams,
}) {
const filters = {
classId: searchParams.classId,
studentId: searchParams.studentId,
templateId: searchParams.templateId,
startDate: searchParams.startDate,
endDate: searchParams.endDate,
page: parseInt(searchParams.page) || 1,
limit: parseInt(searchParams.limit) || 20,
};
const statistics = await getExamStatistics(filters);
return (
<div className="space-y-6">
<PageHeader
title="Estatísticas de Provas"
subtitle="Visualize estatísticas agregadas e métricas de desempenho"
icon={<ChartBarIcon className="w-6 h-6" />}
/>
<ExamStatistics statistics={statistics} filters={filters} />
</div>
);
}
@@ -0,0 +1,483 @@
"use client";
import { useState, useActionState, useEffect } from "react";
import updateUserData from "@/app/lib/users/updateUserAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import RoleCheckbox from "@/app/(protected)/components/shared/RoleCheckbox";
import Label from "@/app/(protected)/components/shared/Label";
import Link from "next/link";
import { formatDateToBR, maskDateToBR } from "@/app/lib/utils/dateUtils";
import { sanitizeUsername, sanitizeFullName, hasInvalidUsernameChars, hasInvalidFullNameChars } from "@/app/lib/utils/stringUtils";
const initialState = {
success: false,
message: "",
inputs: {},
};
const ROLE_OPTIONS = [
{ value: "student", label: "Estudante", color: "emerald" },
{ value: "parent", label: "Responsável", color: "blue" },
{ value: "teacher", label: "Professor", color: "amber" },
{ value: "admin", label: "Administrador", color: "red" },
];
function UpdateUserForm({ user, parents = [], students = [], isAdmin = false }) {
const [state, action, isPending] = useActionState(
updateUserData,
initialState
);
const [showMessage, setShowMessage] = useState(false);
const [fullNameWarning, setFullNameWarning] = useState(false);
const [usernameWarning, setUsernameWarning] = useState(false);
const [inputs, setInputs] = useState({
fullName: user?.fullName || "",
username: user?.username || "",
email: user?.email || "",
dateOfBirth: formatDateToBR(user?.dateOfBirth),
roles: user?.roles || ["student"],
guardiansAccounts: (user?.guardiansAccounts || []).map((g) =>
typeof g === "string" ? g : g.toString()
),
wardAccounts: (user?.wardAccounts || []).map((w) =>
typeof w === "string" ? w : w.toString()
),
});
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 15000);
return () => clearTimeout(timer);
} else {
setShowMessage(false);
}
}, [state.message]);
const onRoleChange = (role) => (e) => {
const currentRoles = inputs.roles || [];
const newRoles = e.target.checked
? [...currentRoles, role]
: currentRoles.filter((r) => r !== role);
setInputs({ ...inputs, roles: newRoles });
};
const onAddGuardian = (guardianId) => {
setInputs({
...inputs,
guardiansAccounts: [...(inputs.guardiansAccounts || []), guardianId],
});
};
const onRemoveGuardian = (guardianId) => {
setInputs({
...inputs,
guardiansAccounts: (inputs.guardiansAccounts || []).filter(
(id) => id !== guardianId
),
});
};
const onAddWard = (wardId) => {
setInputs({
...inputs,
wardAccounts: [...(inputs.wardAccounts || []), wardId],
});
};
const onRemoveWard = (wardId) => {
setInputs({
...inputs,
wardAccounts: (inputs.wardAccounts || []).filter(
(id) => id !== wardId
),
});
};
const getRoleColor = (role) => {
return ROLE_OPTIONS.find((r) => r.value === role)?.color || "gray";
};
return (
<div className="w-full max-w-4xl mx-auto">
{/* FlashMessage */}
{state?.message && showMessage && (
<div className="mb-4">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form action={action} className="max-w-4xl mx-auto">
{isPending && (
<div className="mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-center">
<p className="text-neutral-600 dark:text-neutral-300">Salvando...</p>
</div>
)}
<input type="hidden" name="_id" value={user._id} />
{/* Hidden inputs for arrays */}
{(inputs.roles || []).map((role) => (
<input key={`role-${role}`} type="hidden" name="roles" value={role} />
))}
{(inputs.guardiansAccounts || []).map((guardianId) => (
<input
key={`guardian-${guardianId}`}
type="hidden"
name="guardiansAccounts"
value={guardianId}
/>
))}
{(inputs.wardAccounts || []).map((wardId) => (
<input
key={`ward-${wardId}`}
type="hidden"
name="wardAccounts"
value={wardId}
/>
))}
{/* Main Form - Card Layout */}
<div className="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Editar Usuário
</h2>
</div>
{/* Form Content */}
<div className="p-6">
{/* 2-Column Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Nome Completo */}
<div className="space-y-2">
<label
htmlFor="fullName"
className="text-sm font-medium text-neutral-700 dark:text-neutral-200"
>
Nome Completo <span className="text-red-500">*</span>
</label>
<input
id="fullName"
name="fullName"
required
value={inputs.fullName}
onChange={(e) => {
const raw = e.target.value;
const clean = sanitizeFullName(raw);
setInputs({ ...inputs, fullName: clean });
setFullNameWarning(hasInvalidFullNameChars(raw));
}}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{fullNameWarning && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Acentos, caracteres especiais e maiúsculas são removidos automaticamente. Use apenas letras sem acento.
</p>
)}
</div>
{/* Nome de Usuário */}
<div className="space-y-2">
<label
htmlFor="username"
className="text-sm font-medium text-neutral-700 dark:text-neutral-200"
>
Nome de Usuário <span className="text-red-500">*</span>
</label>
<input
id="username"
name="username"
required
value={inputs.username}
onChange={(e) => {
const raw = e.target.value;
const clean = sanitizeUsername(raw);
setInputs({ ...inputs, username: clean });
setUsernameWarning(hasInvalidUsernameChars(raw));
}}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{usernameWarning && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Acentos, espaços e maiúsculas são removidos automaticamente. Use apenas letras, números e _.
</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium text-neutral-700 dark:text-neutral-200"
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={inputs.email}
onChange={(e) =>
setInputs({ ...inputs, email: e.target.value })
}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="[email protected]"
/>
</div>
{/* Data de Nascimento */}
<div className="space-y-2">
<label
htmlFor="dateOfBirth"
className="text-sm font-medium text-neutral-700 dark:text-neutral-200"
>
Data de Nascimento <span className="text-red-500">*</span>
</label>
<input
type="text"
id="dateOfBirth"
name="dateOfBirth"
required
value={inputs.dateOfBirth}
onChange={(e) =>
setInputs({ ...inputs, dateOfBirth: maskDateToBR(e.target.value) })
}
placeholder="DD/MM/AAAA"
inputMode="numeric"
maxLength={10}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{/* Admin Only Section */}
{isAdmin && (
<>
{/* Roles - Checkboxes */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Funções <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-3">
{ROLE_OPTIONS.map((roleOption) => {
const isChecked = (inputs.roles || []).includes(
roleOption.value
);
return (
<RoleCheckbox
key={roleOption.value}
value={roleOption.value}
label={roleOption.label}
color={roleOption.color}
checked={isChecked}
onChange={onRoleChange(roleOption.value)}
/>
);
})}
</div>
</div>
{/* Guardians - Only for students */}
{(inputs.roles || []).includes("student") && (
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Responsáveis (Guardians)
</label>
{/* Selected Guardians as Removable Tags */}
{(inputs.guardiansAccounts || []).length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700">
{inputs.guardiansAccounts.map((guardianId) => {
const guardian = parents.find(
(p) =>
(typeof p._id === "string"
? p._id
: p._id.toString()) ===
(typeof guardianId === "string"
? guardianId
: guardianId.toString())
);
if (!guardian) return null;
return (
<Label
key={
typeof guardianId === "string"
? guardianId
: guardianId.toString()
}
color="blue"
className="font-medium"
onRemove={() => onRemoveGuardian(guardianId)}
>
{guardian.fullName}
</Label>
);
})}
</div>
)}
{/* Add Guardian Dropdown */}
<div className="flex gap-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
onAddGuardian(e.target.value);
}
}}
className="flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Adicionar responsável...</option>
{parents
.filter(
(p) =>
!(inputs.guardiansAccounts || []).some(
(selectedId) =>
(typeof p._id === "string"
? p._id
: p._id.toString()) ===
(typeof selectedId === "string"
? selectedId
: selectedId.toString())
)
)
.map((parent) => {
const id =
typeof parent._id === "string"
? parent._id
: parent._id.toString();
return (
<option key={id} value={id}>
{parent.fullName}
</option>
);
})}
</select>
</div>
{parents.length === 0 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Nenhum responsável cadastrado.
</p>
)}
</div>
)}
{/* Wards - Only for parents/guardians */}
{(inputs.roles || []).includes("parent") && (
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Estudantes sob Responsabilidade (Wards)
</label>
{/* Selected Wards as Removable Tags */}
{(inputs.wardAccounts || []).length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700">
{inputs.wardAccounts.map((wardId) => {
const ward = students.find(
(s) =>
(typeof s._id === "string"
? s._id
: s._id.toString()) ===
(typeof wardId === "string"
? wardId
: wardId.toString())
);
if (!ward) return null;
return (
<Label
key={
typeof wardId === "string"
? wardId
: wardId.toString()
}
color="emerald"
className="font-medium"
onRemove={() => onRemoveWard(wardId)}
>
{ward.fullName}
</Label>
);
})}
</div>
)}
{/* Add Ward Dropdown */}
<div className="flex gap-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
onAddWard(e.target.value);
}
}}
className="flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">Adicionar estudante...</option>
{students
.filter(
(s) =>
!(inputs.wardAccounts || []).some(
(selectedId) =>
(typeof s._id === "string"
? s._id
: s._id.toString()) ===
(typeof selectedId === "string"
? selectedId
: selectedId.toString())
)
)
.map((student) => {
const id =
typeof student._id === "string"
? student._id
: student._id.toString();
return (
<option key={id} value={id}>
{student.fullName}
</option>
);
})}
</select>
</div>
{students.length === 0 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Nenhum estudante cadastrado.
</p>
)}
</div>
)}
</>
)}
</div>
{/* Action Buttons */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex gap-3 justify-between">
<Link
href="/admin/dashboard/users"
className="px-6 py-2.5 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Voltar
</Link>
<button
type="submit"
disabled={isPending}
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</div>
</form>
</div>
);
}
export default UpdateUserForm;
@@ -0,0 +1,385 @@
"use client";
import { deleteUser } from "@/app/lib/users/deleteUser";
import { generatePasswordResetTokenAction } from "@/app/lib/users/resetPasswordActions";
import { useActionState } from "react";
import { useEffect, useState, useRef, useMemo } from "react";
import Link from "next/link";
import { FaEdit, FaTrash, FaKey, FaSearch, FaChevronLeft, FaChevronRight } from "react-icons/fa";
import { IoCalendarOutline } from "react-icons/io5";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
const ITEMS_PER_PAGE = 15;
export default function UsersTable({ users }) {
const deleteInitialState = { success: false, message: null };
const [state, action, isPending] = useActionState(deleteUser, deleteInitialState);
const [showMessage, setShowMessage] = useState(false);
const [resetModal, setResetModal] = useState({ open: false, userId: null, userName: "" });
const [resetLoading, setResetLoading] = useState(false);
const [resetResult, setResetResult] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const linkRef = useRef(null);
const sortedUsers = useMemo(() => {
return [...users].sort((a, b) =>
a.fullName.localeCompare(b.fullName, "pt-BR", { sensitivity: "base" })
);
}, [users]);
const filteredUsers = useMemo(() => {
if (!searchTerm.trim()) return sortedUsers;
const term = searchTerm.toLowerCase().trim();
return sortedUsers.filter(
(user) =>
user.fullName.toLowerCase().includes(term) ||
user.username.toLowerCase().includes(term) ||
(user.email && user.email.toLowerCase().includes(term))
);
}, [sortedUsers, searchTerm]);
const totalPages = Math.max(1, Math.ceil(filteredUsers.length / ITEMS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const paginatedUsers = filteredUsers.slice(
(safePage - 1) * ITEMS_PER_PAGE,
safePage * ITEMS_PER_PAGE
);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
const handleGenerateResetLink = async () => {
if (!resetModal.userId) return;
setResetLoading(true);
setResetResult(null);
try {
const result = await generatePasswordResetTokenAction(resetModal.userId);
setResetResult(result);
} catch {
setResetResult({ success: false, message: "Erro inesperado." });
} finally {
setResetLoading(false);
}
};
const handleCopyLink = () => {
if (linkRef.current) {
navigator.clipboard.writeText(linkRef.current.value);
}
};
const closeResetModal = () => {
setResetModal({ open: false, userId: null, userName: "" });
setResetResult(null);
setResetLoading(false);
};
return (
<>
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
placeholder="Buscar por nome, usuário ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
/>
</div>
<span className="text-xs text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
{filteredUsers.length} usuário{filteredUsers.length !== 1 ? "s" : ""} encontrado{filteredUsers.length !== 1 ? "s" : ""}
</span>
</div>
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Nome Completo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Nome de Usuário
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Email
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell text-center">
Data de Nascimento
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell">
Funções
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{paginatedUsers.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-neutral-400 dark:text-neutral-500">
Nenhum usuário encontrado.
</td>
</tr>
) : (
paginatedUsers.map((user) => (
<tr
key={user._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
{user.fullName}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{user.username}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{user.email || "-"}
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300 text-center">
<span className="inline-flex items-center gap-1.5">
<IoCalendarOutline className="text-neutral-400" />
{new Date(user.dateOfBirth).toLocaleDateString('pt-BR', {timeZone: 'UTC'})}
</span>
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300">
<div className="flex flex-wrap gap-1.5">
{user.roles.length > 0 ? (
user.roles.map(role => {
const roleColors = {
student: "emerald",
parent: "blue",
teacher: "amber",
admin: "red",
};
return (
<Label key={role} color={roleColors[role] || "gray"} size="sm" uppercase>
{role}
</Label>
);
})
) : (
<span className="text-neutral-400 dark:text-neutral-600">-</span>
)}
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
<button
type="button"
title="Resetar Senha"
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
onClick={() => setResetModal({ open: true, userId: user._id, userName: user.fullName })}
>
<FaKey className="text-lg" />
</button>
<Link
href={`/admin/dashboard/users/edit/${user._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input type="hidden" name="userId" value={user._id} />
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Excluir usuário ${user.username}`}
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar este usuário?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Página {safePage} de {totalPages}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="p-2 rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<FaChevronLeft className="text-xs" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
if (totalPages <= 7) return true;
if (page === 1 || page === totalPages) return true;
if (Math.abs(page - safePage) <= 1) return true;
return false;
})
.reduce((acc, page, idx, arr) => {
if (idx > 0 && page - arr[idx - 1] > 1) {
acc.push("...");
}
acc.push(page);
return acc;
}, [])
.map((item, idx) =>
item === "..." ? (
<span key={`ellipsis-${idx}`} className="px-2 text-neutral-400 text-xs">...</span>
) : (
<button
key={item}
onClick={() => setCurrentPage(item)}
className={`min-w-[32px] h-8 rounded-lg text-xs font-medium transition-colors ${
safePage === item
? "bg-indigo-600 text-white"
: "text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
}`}
>
{item}
</button>
)
)}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="p-2 rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<FaChevronRight className="text-xs" />
</button>
</div>
</div>
)}
</div>
{resetModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeResetModal}>
<div className="bg-white dark:bg-neutral-800 rounded-2xl shadow-2xl max-w-lg w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
Resetar Senha
</h3>
<button onClick={closeResetModal} className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Gerar link de redefinição de senha para <strong className="text-neutral-900 dark:text-neutral-100">{resetModal.userName}</strong>.
O link é válido por 24 horas e deve ser enviado manualmente ao usuário.
</p>
{!resetResult && (
<button
onClick={handleGenerateResetLink}
disabled={resetLoading}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{resetLoading ? (
<>
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Gerando...
</>
) : (
<>
<FaKey className="text-sm" />
Gerar Link de Reset
</>
)}
</button>
)}
{resetResult?.success && resetResult.data && (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-700">
<p className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">
Link gerado com sucesso! Expira em 24 horas.
</p>
</div>
<div className="flex gap-2">
<input
ref={linkRef}
type="text"
readOnly
value={resetResult.data.resetLink}
className="flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-700 text-xs text-neutral-900 dark:text-neutral-100 font-mono"
/>
<button
onClick={handleCopyLink}
className="px-4 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors whitespace-nowrap"
>
Copiar
</button>
</div>
<button
onClick={closeResetModal}
className="w-full py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Fechar
</button>
</div>
)}
{resetResult && !resetResult.success && (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
<p className="text-sm text-red-700 dark:text-red-300">{resetResult.message}</p>
</div>
<button
onClick={() => setResetResult(null)}
className="w-full py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Tentar novamente
</button>
</div>
)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,66 @@
import { getUserModel } from "@/app/models/User";
import UpdateUserForm from "@/app/(protected)/admin/dashboard/users/components/UpdateUserForm";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { toPlain } from "@/app/lib/helpers/toPlain";
import { getUsersByRole } from "@/app/lib/users/getUsersByRole";
import { auth } from "@/auth";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function UpdateUserPage({ params }) {
const { id } = await params;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const session = await auth();
const User = await getUserModel();
const userDoc = await User.findById(id)
.select("-passwordHash -createdAt -modifiedAt -__v")
.lean();
if (!userDoc) {
return <NotAuthorized />;
}
if (!session) {
return <NotAuthorized />;
}
const user = toPlain(userDoc);
user._id = user._id.toString();
const isOwner = session.user.id === id;
const isGuardian = user.guardiansAccounts.some(g => g.toString() === session.user.id);
// Check admin role regardless of owner/guardian status
const sessionUser = await User.findById(session.user.id).select("roles").lean();
const isAdmin = sessionUser?.roles?.includes("admin") || false;
if (!isOwner && !isGuardian && !isAdmin) {
return <NotAuthorized />;
}
const parentsResult = await getUsersByRole(["parent"]);
const parents = parentsResult.success ? parentsResult.data : [];
const studentsResult = await getUsersByRole(["student"]);
const students = studentsResult.success ? studentsResult.data : [];
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Usuário"
subtitle="Atualize as informações e permissões do usuário"
/>
<div className="flex justify-center">
<UpdateUserForm user={user} parents={parents} students={students} isAdmin={isAdmin} />
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import UsersTable from "./components/UsersTable";
import { getUsersForAdminAction } from "@/app/lib/users/actions";
export default async function UsersAdminPage() {
const result = await getUsersForAdminAction();
const users = result.success ? result.data : [];
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Usuários e Papéis"
subtitle="Configure permissões e gerencie contas de professores e alunos"
/>
<UsersTable users={users}/>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import SideBarNav from "../components/shared/SideBarNav";
import { auth } from "@/app/lib/utils/auth";
import { redirect } from "next/navigation";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
export default async function AdminLayout({ children }) {
const session = await auth();
// If not authenticated, redirect to login
if (!session?.user?.id) {
redirect("/auth/login");
}
// If not admin, render without sidebar
const isAdmin = session.user.roles?.includes("admin");
if (!isAdmin) {
return <NotAuthorized />;
}
return (
<div className="flex h-screen overflow-hidden bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
<SideBarNav />
<div className="flex-1 min-w-0 overflow-x-hidden overflow-y-auto">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{children}
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import {
BookmarkIcon,
UserGroupIcon,
UsersIcon,
CurrencyDollarIcon,
AcademicCapIcon,
ChatBubbleLeftRightIcon,
} from "@heroicons/react/24/outline";
import DashboardCard from "./shared/DashboardCard";
import PageHeader from "./shared/PageHeader";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
import { getContactMessageModel } from "@/app/models/ContactMessage";
export const dynamic = "force-dynamic";
async function AdminDashboard() {
const ContactMessage = await getContactMessageModel();
const unreadCount = await ContactMessage.countDocuments({ status: "new" });
return (
<div className="max-w-6xl mx-auto p-6">
<PageHeader
title="Dashboard Administrativo"
subtitle="Gerencie os gêneros de classes, classes, usuários e seus papéis."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<DashboardCard
title="Tipos de Turmas"
description="Gerencie os tipos de turmas, como 'Starters' etc."
buttonText="Gerenciar"
buttonColor="indigo"
link={`/admin/dashboard/${relatedToTitleUrl("classTypes")}`}
icon={BookmarkIcon}
/>
<DashboardCard
title="Turmas"
description="Gerencie as turmas ativas atualmente, bem como as arquivadas."
buttonText="Gerenciar"
buttonColor="emerald"
link="/admin/dashboard/class"
icon={UserGroupIcon}
/>
<DashboardCard
title="Usuários & Papéis"
description="Configure permissões e gerencie contas de professores e alunos."
buttonText="Gerenciar Usuários"
buttonColor="purple"
link="/admin/dashboard/users"
icon={UsersIcon}
/>
<DashboardCard
title="Pagamentos"
description="Visualize e gerencie os pagamentos realizados e pendentes dos alunos."
buttonText="Ver Pagamentos"
buttonColor="amber"
link="/admin/dashboard/payments"
icon={CurrencyDollarIcon}
/>
<DashboardCard
title="Provas"
description="Gerencie as provas, crie novas provas e visualize os resultados."
buttonText="Gerenciar Provas"
buttonColor="blue"
link="/admin/dashboard/exams"
icon={AcademicCapIcon}
/>
<DashboardCard
title={unreadCount > 0 ? `Mensagens (${unreadCount} novas)` : "Mensagens"}
description="Visualize e responda as mensagens recebidas pelo formulário de contato."
buttonText="Ver Mensagens"
buttonColor="rose"
link="/admin/dashboard/messages"
icon={ChatBubbleLeftRightIcon}
/>
</div>
</div>
);
}
export default AdminDashboard;
@@ -0,0 +1,10 @@
import PageTitle from "./shared/PageTitle";
export function DashboardLayout({ title, subtitle, children, className = "" }) {
return (
<div className={`max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 ${className}`}>
{title && <PageTitle title={title} subTitle={subtitle} />}
{children}
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { XMarkIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
import { createExamAssignment, updateExamAssignment } from "@/app/lib/actions/examActions";
export default function AssignmentForm({
isOpen,
classId: initialClassId,
templates = [],
classes = [],
assignment = null, // For editing mode
isEditing = false,
}) {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [selectedClassId, setSelectedClassId] = useState(initialClassId || "");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [instructions, setInstructions] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [timeLimit, setTimeLimit] = useState("");
const [allowRetakes, setAllowRetakes] = useState(false);
const [maxAttempts, setMaxAttempts] = useState("1");
const [showResultsAfterGrading, setShowResultsAfterGrading] = useState(true);
const [status, setStatus] = useState("active");
// Custom questions state
const [useCustomQuestions, setUseCustomQuestions] = useState(false);
const [customQuestions, setCustomQuestions] = useState([]);
const handleClose = () => {
window.dispatchEvent(new CustomEvent('closeAssignmentModal'));
};
// Initialize form when editing
useEffect(() => {
if (isEditing && assignment) {
setSelectedClassId(assignment.classId?._id || assignment.classId || "");
setSelectedTemplateId(assignment.examTemplateId?._id || assignment.examTemplateId || "");
setTitle(assignment.title || "");
setDescription(assignment.description || "");
setInstructions(assignment.instructions || "");
setStartDate(assignment.startDate ? new Date(assignment.startDate).toISOString().slice(0, 16) : "");
setEndDate(assignment.endDate ? new Date(assignment.endDate).toISOString().slice(0, 16) : "");
setTimeLimit(assignment.timeLimit ? String(assignment.timeLimit) : "");
setAllowRetakes(assignment.allowRetakes || false);
setMaxAttempts(String(assignment.maxAttempts || 1));
setShowResultsAfterGrading(assignment.showResultsAfterGrading !== false);
setStatus(assignment.status || "active");
setUseCustomQuestions(assignment.useCustomQuestions || false);
setCustomQuestions(assignment.customQuestions?.map((q, idx) => ({
...q,
_id: q._id || `temp-${idx}`,
options: q.options?.map((opt, optIdx) => ({
...opt,
_id: opt._id || `temp-opt-${idx}-${optIdx}`,
})) || [],
})) || []);
} else if (isOpen && !isEditing) {
// Reset form for new assignment
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 7);
setStartDate(now.toISOString().slice(0, 16));
setEndDate(tomorrow.toISOString().slice(0, 16));
setTitle("");
setDescription("");
setInstructions("");
setSelectedTemplateId("");
setTimeLimit("");
setAllowRetakes(false);
setMaxAttempts("1");
setShowResultsAfterGrading(true);
setStatus("active");
setUseCustomQuestions(false);
setCustomQuestions([]);
}
}, [isOpen, isEditing, assignment]);
const handleTemplateChange = (e) => {
const templateId = e.target.value;
setSelectedTemplateId(templateId);
const template = templates.find(t => t._id === templateId);
if (template && !isEditing) {
setTitle(template.title);
setDescription(template.description || "");
setInstructions(template.instructions || "");
setTimeLimit(template.timeLimit ? String(template.timeLimit) : "");
// Always initialize custom questions from template
// This allows the teacher to customize (edit/delete/add) when they enable the toggle
if (template.questions) {
setCustomQuestions(template.questions.map((q, idx) => ({
_id: `temp-${idx}`,
questionText: q.questionText,
questionType: q.questionType,
options: q.options?.map((opt, optIdx) => ({
_id: `temp-opt-${idx}-${optIdx}`,
optionText: opt.optionText,
isCorrect: opt.isCorrect,
})) || [],
points: q.points || 1,
order: q.order || idx,
})));
} else {
setCustomQuestions([]);
}
// Reset useCustomQuestions to false when changing template
// Teacher needs to explicitly enable it to use custom questions
setUseCustomQuestions(false);
}
};
// Question management functions
const addQuestion = () => {
setCustomQuestions([...customQuestions, {
_id: `temp-${Date.now()}`,
questionText: "",
questionType: "multiple_choice",
options: [
{ _id: `temp-opt-${Date.now()}-0`, optionText: "", isCorrect: false },
{ _id: `temp-opt-${Date.now()}-1`, optionText: "", isCorrect: false },
],
points: 1,
order: customQuestions.length,
}]);
};
const removeQuestion = (questionIndex) => {
const newQuestions = customQuestions.filter((_, idx) => idx !== questionIndex);
// Reorder remaining questions
newQuestions.forEach((q, idx) => { q.order = idx; });
setCustomQuestions(newQuestions);
};
const updateQuestion = (questionIndex, field, value) => {
const newQuestions = [...customQuestions];
newQuestions[questionIndex][field] = value;
setCustomQuestions(newQuestions);
};
const addOption = (questionIndex) => {
const newQuestions = [...customQuestions];
newQuestions[questionIndex].options.push({
_id: `temp-opt-${Date.now()}`,
optionText: "",
isCorrect: false,
});
setCustomQuestions(newQuestions);
};
const removeOption = (questionIndex, optionIndex) => {
const newQuestions = [...customQuestions];
newQuestions[questionIndex].options = newQuestions[questionIndex].options.filter((_, idx) => idx !== optionIndex);
setCustomQuestions(newQuestions);
};
const updateOption = (questionIndex, optionIndex, field, value) => {
const newQuestions = [...customQuestions];
newQuestions[questionIndex].options[optionIndex][field] = value;
setCustomQuestions(newQuestions);
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsPending(true);
const assignmentData = {
examTemplateId: selectedTemplateId,
title,
description,
instructions,
startDate: new Date(startDate).toISOString(),
endDate: new Date(endDate).toISOString(),
timeLimit: timeLimit ? parseInt(timeLimit) : null,
allowRetakes,
maxAttempts: parseInt(maxAttempts),
showResultsAfterGrading,
status,
useCustomQuestions,
customQuestions: useCustomQuestions ? customQuestions.map(q => ({
questionText: q.questionText,
questionType: q.questionType,
options: q.options.map(opt => ({
optionText: opt.optionText,
isCorrect: opt.isCorrect,
})),
points: q.points,
order: q.order,
})) : [],
};
try {
let result;
if (isEditing && assignment) {
result = await updateExamAssignment(assignment._id, assignmentData);
} else {
result = await createExamAssignment(selectedClassId, assignmentData);
}
if (result.success) {
handleClose();
router.refresh();
} else {
alert(result.error || `Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova.`);
}
} catch (error) {
console.error(`Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova:`, error);
alert(error.message || `Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova.`);
} finally {
setIsPending(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-neutral-200 dark:border-neutral-800 flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-800 shrink-0">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{isEditing ? "Editar Prova" : "Atribuir Prova à Turma"}
</h2>
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Select Class - hidden when editing or when classId is pre-selected */}
{!initialClassId && !isEditing && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Selecionar Turma *
</label>
<select
value={selectedClassId}
onChange={(e) => setSelectedClassId(e.target.value)}
required
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">Escolha uma turma...</option>
{classes.map((cls) => (
<option key={cls._id} value={cls._id}>
{cls.classTitle}
</option>
))}
</select>
</div>
)}
{(initialClassId || isEditing) && (
<input type="hidden" name="classId" value={selectedClassId} />
)}
{/* Select Template */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Selecionar Template *
</label>
<select
value={selectedTemplateId}
onChange={handleTemplateChange}
required
disabled={isEditing}
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-60"
>
<option value="">Escolha um template...</option>
{templates.map((template) => (
<option key={template._id} value={template._id}>
{template.title} ({template.questions?.length || 0} questões, {template.totalPoints || 0} pts)
</option>
))}
</select>
</div>
{selectedTemplateId && (
<>
{/* Title and Description */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Título da Prova *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Descrição (opcional, substitui a do template)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Instruções (opcional, substitui as do template)
</label>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={3}
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
</div>
{/* Schedule */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Data/Hora Início *
</label>
<input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Data/Hora Fim *
</label>
<input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Tempo (min)
</label>
<input
type="number"
value={timeLimit}
onChange={(e) => setTimeLimit(e.target.value)}
min="1"
placeholder="Override"
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Máx. Tentativas
</label>
<input
type="number"
value={maxAttempts}
onChange={(e) => setMaxAttempts(e.target.value)}
min="1"
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allowRetakes"
checked={allowRetakes}
onChange={(e) => setAllowRetakes(e.target.checked)}
className="rounded border-neutral-300 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="allowRetakes" className="text-sm text-neutral-700 dark:text-neutral-300">
Permitir repetições
</label>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="active">Ativo</option>
<option value="archived">Arquivado</option>
</select>
</div>
</div>
{/* Custom Questions Toggle */}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-neutral-900 dark:text-neutral-100">
Questões Customizadas
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Ative para personalizar as questões desta prova específica
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={useCustomQuestions}
onChange={(e) => setUseCustomQuestions(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
</label>
</div>
</div>
{/* Custom Questions Editor */}
{useCustomQuestions && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-md font-medium text-neutral-900 dark:text-neutral-100">
Questões ({customQuestions.length})
</h4>
<button
type="button"
onClick={addQuestion}
className="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300 dark:hover:bg-indigo-900/30"
>
<PlusIcon className="w-4 h-4" />
Adicionar Questão
</button>
</div>
{customQuestions.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
Nenhuma questão adicionada. Clique em "Adicionar Questão" para começar.
</p>
) : (
<div className="space-y-4">
{customQuestions.map((question, qIndex) => (
<div
key={question._id}
className="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-neutral-50 dark:bg-neutral-800/50"
>
<div className="flex items-start justify-between gap-4 mb-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
Questão {qIndex + 1}
</span>
<button
type="button"
onClick={() => removeQuestion(qIndex)}
className="text-red-500 hover:text-red-600"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
{/* Question Text */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Texto da Questão *
</label>
<textarea
value={question.questionText}
onChange={(e) => updateQuestion(qIndex, 'questionText', e.target.value)}
rows={2}
required
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Question Type */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Tipo *
</label>
<select
value={question.questionType}
onChange={(e) => updateQuestion(qIndex, 'questionType', e.target.value)}
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="multiple_choice">Múltipla Escolha</option>
<option value="text">Texto</option>
</select>
</div>
{/* Points */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Pontos *
</label>
<input
type="number"
value={question.points}
onChange={(e) => updateQuestion(qIndex, 'points', parseInt(e.target.value) || 1)}
min="1"
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
{/* Options for multiple choice */}
{question.questionType === 'multiple_choice' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Opções *
</label>
<button
type="button"
onClick={() => addOption(qIndex)}
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
>
+ Adicionar Opção
</button>
</div>
{question.options.map((option, oIndex) => (
<div key={option._id} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${qIndex}`}
checked={option.isCorrect}
onChange={() => {
const newQuestions = [...customQuestions];
newQuestions[qIndex].options.forEach((opt, idx) => {
opt.isCorrect = idx === oIndex;
});
setCustomQuestions(newQuestions);
}}
className="w-4 h-4 text-indigo-600 border-neutral-300 focus:ring-indigo-500"
/>
<input
type="text"
value={option.optionText}
onChange={(e) => updateOption(qIndex, oIndex, 'optionText', e.target.value)}
placeholder={`Opção ${oIndex + 1}`}
required
className="flex-1 border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-1.5 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
{question.options.length > 2 && (
<button
type="button"
onClick={() => removeOption(qIndex, oIndex)}
className="text-red-500 hover:text-red-600"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</form>
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200 dark:border-neutral-800 shrink-0">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
onClick={handleSubmit}
disabled={isPending || !selectedTemplateId || (useCustomQuestions && customQuestions.length === 0)}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Salvando..." : (isEditing ? "Atualizar Prova" : "Atribuir Prova")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import React, { useState, useEffect } from "react";
import AssignmentForm from "./AssignmentForm";
import { getExamAssignmentById } from "@/app/lib/actions/examActions";
export default function AssignmentFormWrapper({ templates, classes }) {
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedClassId, setSelectedClassId] = useState("");
const [editingAssignment, setEditingAssignment] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const handleOpen = async (e) => {
const { classId, assignmentId } = e.detail || {};
if (assignmentId) {
// Edit mode
setIsLoading(true);
setIsEditing(true);
try {
const result = await getExamAssignmentById(assignmentId);
if (result.success) {
setEditingAssignment(result.data);
setSelectedClassId(result.data.classId?._id || result.data.classId || "");
setIsOpen(true);
} else {
console.error("Error loading assignment:", result.error);
alert("Erro ao carregar prova para edição.");
}
} catch (error) {
console.error("Error loading assignment:", error);
alert("Erro ao carregar prova para edição.");
} finally {
setIsLoading(false);
}
} else {
// Create mode
setIsEditing(false);
setEditingAssignment(null);
setSelectedClassId(classId || "");
setIsOpen(true);
}
};
const handleClose = () => {
setIsOpen(false);
setIsEditing(false);
setEditingAssignment(null);
setSelectedClassId("");
};
window.addEventListener('open-assignment-form', handleOpen);
window.addEventListener('closeAssignmentModal', handleClose);
return () => {
window.removeEventListener('open-assignment-form', handleOpen);
window.removeEventListener('closeAssignmentModal', handleClose);
};
}, []);
if (isLoading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6">
<p className="text-neutral-600 dark:text-neutral-400">Carregando...</p>
</div>
</div>
);
}
return (
<AssignmentForm
isOpen={isOpen}
classId={selectedClassId}
templates={templates}
classes={classes}
assignment={editingAssignment}
isEditing={isEditing}
/>
);
}
@@ -0,0 +1,432 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { XMarkIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
import { createExamTemplate, updateExamTemplate } from "@/app/lib/actions/examActions";
export default function TemplateForm({
isOpen,
template = null
}) {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [instructions, setInstructions] = useState("");
const [timeLimit, setTimeLimit] = useState("");
const [category, setCategory] = useState("");
const [tags, setTags] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [questions, setQuestions] = useState([]);
const handleClose = () => {
window.dispatchEvent(new CustomEvent('closeTemplateModal'));
};
useEffect(() => {
if (template) {
setTitle(template.title || "");
setDescription(template.description || "");
setInstructions(template.instructions || "");
setTimeLimit(template.timeLimit || "");
setCategory(template.category || "");
setTags(template.tags?.join(", ") || "");
setIsPublic(template.isPublic || false);
setQuestions(template.questions || []);
} else {
resetForm();
}
}, [template, isOpen]);
const resetForm = () => {
setTitle("");
setDescription("");
setInstructions("");
setTimeLimit("");
setCategory("");
setTags("");
setIsPublic(false);
setQuestions([]);
};
const addQuestion = () => {
const newQuestion = {
questionText: "",
questionType: "multiple_choice",
options: [
{ optionText: "", isCorrect: false },
{ optionText: "", isCorrect: false },
],
points: 1,
order: questions.length,
};
setQuestions([...questions, newQuestion]);
};
const updateQuestion = (index, field, value) => {
const updated = [...questions];
updated[index][field] = value;
setQuestions(updated);
};
const deleteQuestion = (index) => {
const updated = questions.filter((_, i) => i !== index);
updated.forEach((q, i) => q.order = i);
setQuestions(updated);
};
const addOption = (questionIndex) => {
const updated = [...questions];
updated[questionIndex].options.push({ optionText: "", isCorrect: false });
setQuestions(updated);
};
const updateOption = (questionIndex, optionIndex, field, value) => {
const updated = [...questions];
updated[questionIndex].options[optionIndex][field] = value;
if (field === "isCorrect" && value === true) {
updated[questionIndex].options.forEach((opt, i) => {
if (i !== optionIndex) opt.isCorrect = false;
});
}
setQuestions(updated);
};
const deleteOption = (questionIndex, optionIndex) => {
const updated = [...questions];
updated[questionIndex].options = updated[questionIndex].options.filter(
(_, i) => i !== optionIndex
);
setQuestions(updated);
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsPending(true);
// Filter out empty questions
const validQuestions = questions.filter(q => q.questionText.trim() !== "");
for (const q of validQuestions) {
if (q.questionType === "multiple_choice") {
const validOptions = q.options.filter(o => o.optionText.trim() !== "");
if (validOptions.length < 2) {
alert("Cada questão de múltipla escolha deve ter pelo menos 2 opções.");
setIsPending(false);
return;
}
}
}
const templateData = {
title,
description,
instructions,
questions: validQuestions.map((q, i) => ({
...q,
order: i,
})),
timeLimit: timeLimit ? parseInt(timeLimit) : null,
category: category || null,
tags: tags ? tags.split(",").map(t => t.trim()).filter(Boolean) : [],
isPublic,
};
try {
const result = template
? await updateExamTemplate(template._id, templateData)
: await createExamTemplate(templateData);
if (result.success) {
handleClose();
resetForm();
router.refresh();
} else {
alert(result.error || "Erro ao salvar template.");
}
} catch (error) {
console.error("Erro ao salvar template:", error);
alert("Erro ao salvar template.");
} finally {
setIsPending(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-neutral-200 dark:border-neutral-800 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-neutral-200 dark:border-neutral-800 shrink-0">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{template ? "Editar Template" : "Novo Template de Prova"}
</h2>
<button onClick={handleClose} className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Basic Info */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Título do Template *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
placeholder="Ex: Prova de Inglês - Unidade 1"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Descrição
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Breve descrição do template..."
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Instruções para os alunos
</label>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={2}
placeholder="Leia atentamente todas as questões antes de responder..."
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Tempo Limite (minutos)
</label>
<input
type="number"
value={timeLimit}
onChange={(e) => setTimeLimit(e.target.value)}
min="1"
placeholder="Ex: 60"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Categoria
</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Ex: Inglês, Matemática"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Tags (separadas por vírgula)
</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Ex: básico, revisão"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="isPublic" className="text-sm text-neutral-700 dark:text-neutral-300">
Tornar público (outros professores podem usar)
</label>
</div>
</div>
{/* Questions */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-medium text-neutral-900 dark:text-neutral-100">
Questões
</h3>
<button
type="button"
onClick={addQuestion}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Adicionar Questão
</button>
</div>
<div className="space-y-4">
{questions.map((question, qIndex) => (
<div
key={qIndex}
className="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-neutral-50 dark:bg-neutral-800/50"
>
<div className="flex items-start justify-between mb-3">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Questão {qIndex + 1}
</span>
<button
type="button"
onClick={() => deleteQuestion(qIndex)}
className="text-red-500 hover:text-red-700 dark:hover:text-red-400"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Tipo de Questão
</label>
<select
value={question.questionType}
onChange={(e) => updateQuestion(qIndex, "questionType", e.target.value)}
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="multiple_choice">Múltipla Escolha</option>
<option value="text">Resposta Textual</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Enunciado *
</label>
<textarea
value={question.questionText}
onChange={(e) => updateQuestion(qIndex, "questionText", e.target.value)}
rows={2}
required
placeholder="Digite o enunciado da questão..."
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Pontos
</label>
<input
type="number"
value={question.points}
onChange={(e) => updateQuestion(qIndex, "points", parseInt(e.target.value) || 0)}
min="0"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{question.questionType === "multiple_choice" && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Opções de Resposta
</label>
<button
type="button"
onClick={() => addOption(qIndex)}
className="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300"
>
+ Adicionar Opção
</button>
</div>
<div className="space-y-2">
{question.options.map((option, oIndex) => (
<div key={oIndex} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${qIndex}`}
checked={option.isCorrect}
onChange={() => updateOption(qIndex, oIndex, "isCorrect", true)}
className="flex-shrink-0 text-indigo-600 focus:ring-indigo-500 dark:border-neutral-600"
title="Marcar como correta"
/>
<input
type="text"
value={option.optionText}
onChange={(e) => updateOption(qIndex, oIndex, "optionText", e.target.value)}
placeholder={`Opção ${oIndex + 1}`}
className="min-w-0 flex-1 border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-1.5 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
/>
{question.options.length > 2 && (
<button
type="button"
onClick={() => deleteOption(qIndex, oIndex)}
className="flex-shrink-0 p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Marque a opção correta com o botão de rádio à esquerda.
</p>
</div>
)}
{question.questionType === "text" && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Esta questão será corrigida manualmente pelo professor.
</p>
)}
</div>
</div>
))}
</div>
{questions.length === 0 && (
<div className="text-center py-8 text-neutral-500 dark:text-neutral-400 border-2 border-dashed border-neutral-200 dark:border-neutral-700 rounded-lg">
Nenhuma questão adicionada ainda.
</div>
)}
</div>
</form>
<div className="flex justify-end gap-3 p-4 border-t border-neutral-200 dark:border-neutral-800 shrink-0">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
onClick={handleSubmit}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Salvando..." : template ? "Atualizar" : "Criar Template"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import React, { useState, useTransition, useCallback, useEffect } from "react";
import Link from "next/link";
import {
AcademicCapIcon,
PlusIcon,
PencilSquareIcon,
TrashIcon,
EyeIcon,
DocumentDuplicateIcon,
} from "@heroicons/react/24/outline";
import ActionBtn from "@/app/(protected)/dashboard/components/ActionBtn";
import Card from "@/app/(protected)/dashboard/components/Card";
import SectionHeader from "@/app/(protected)/dashboard/components/SectionHeader";
import Label from "@/app/(protected)/components/shared/Label";
import { deleteExamTemplate, duplicateExamTemplate } from "@/app/lib/actions/examActions";
export default function TemplateList({
templates: initialTemplates = [],
setTemplates: setTemplatesProp
}) {
const [templates, setTemplates] = useState(initialTemplates);
const [isPending, startTransition] = useTransition();
const [pendingDeleteId, setPendingDeleteId] = useState(null);
// Sync with parent prop when it changes
useEffect(() => {
setTemplates(initialTemplates);
}, [initialTemplates]);
// Wrapper to update both local and parent state
const updateTemplates = useCallback((updater) => {
setTemplates(updater);
if (setTemplatesProp) {
setTemplatesProp(typeof updater === 'function' ? updater(templates) : updater);
}
}, [setTemplatesProp, templates]);
const handleDelete = useCallback(async (templateId) => {
if (!confirm("Tem certeza que deseja excluir este template?")) {
return;
}
// Set pending state for UI feedback
setPendingDeleteId(templateId);
startTransition(() => {
// Perform the delete operation
deleteExamTemplate(templateId).then((result) => {
if (result.success) {
updateTemplates((prev) => prev.filter((t) => t._id !== templateId));
} else {
alert(result.error || "Erro ao excluir template");
}
}).catch((error) => {
console.error("Erro ao excluir template:", error);
alert("Erro ao excluir template");
}).finally(() => {
setPendingDeleteId(null);
});
});
}, [updateTemplates]);
const handleDuplicate = useCallback(async (templateId) => {
startTransition(() => {
duplicateExamTemplate(templateId).then((result) => {
if (result.success && result.data) {
// Ensure the duplicated template has a unique _id
const newTemplate = result.data;
console.log('Duplicated template _id:', newTemplate._id);
updateTemplates((prev) => {
// Check if _id already exists
if (prev.some(t => t._id === newTemplate._id)) {
console.warn('Duplicate _id detected, forcing refresh');
window.location.reload();
return prev;
}
return [newTemplate, ...prev];
});
} else {
alert(result.error || "Erro ao duplicar template");
}
}).catch((error) => {
console.error("Erro ao duplicar template:", error);
alert("Erro ao duplicar template");
});
});
}, [updateTemplates]);
// Use templates for rendering
const displayTemplates = templates;
return (
<Card>
<div className="flex items-center justify-between flex-wrap gap-2">
<SectionHeader title="Meus Templates" icon={<AcademicCapIcon className="w-5 h-5" />} />
<ActionBtn
icon={<PlusIcon className="w-5 h-5" />}
label="Novo Template"
onClick={() => {
// This will be handled by the parent component
window.dispatchEvent(new CustomEvent('open-template-form', { detail: null }));
}}
/>
</div>
<div className="mt-4 space-y-3">
{displayTemplates.length === 0 ? (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nenhum template criado ainda.
</p>
) : (
displayTemplates.map((template) => (
<div
key={template._id}
className={`hover-card-light p-4 border border-neutral-200 dark:border-neutral-800 rounded-lg ${(isPending || pendingDeleteId === template._id) ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 key={`${template._id}-title`} className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{template.title}
</h3>
{template.isPublic && (
<Label key={`${template._id}-public`} color="blue" size="sm">Público</Label>
)}
{template.category && (
<Label key={`${template._id}-category`} color="gray" size="sm">{template.category}</Label>
)}
</div>
{template.description && (
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 line-clamp-1">
{template.description}
</p>
)}
<div className="mt-2 flex items-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
<span key={`${template._id}-questions`}>{template.questions?.length || 0} questões</span>
<span key={`${template._id}-points`}>{template.totalPoints || 0} pontos</span>
{template.timeLimit && <span key={`${template._id}-time`}>{template.timeLimit} min</span>}
<span key={`${template._id}-usage`}>Usado {template.usageCount || 0}x</span>
</div>
{template.tags && template.tags.length > 0 && (
<div className="mt-2 flex gap-1 flex-wrap">
{template.tags.map((tag, idx) => (
<span key={`${template._id}-tag-${idx}`} className="text-xs px-2 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 flex-shrink-0">
<ActionBtn
key={`${template._id}-duplicate`}
icon={<DocumentDuplicateIcon className="w-4 h-4" />}
label="Duplicar"
onClick={() => handleDuplicate(template._id)}
variant="secondary"
small
disabled={isPending || pendingDeleteId === template._id}
/>
<ActionBtn
key={`${template._id}-edit`}
icon={<PencilSquareIcon className="w-4 h-4" />}
label="Editar"
onClick={() => {
// This will be handled by the parent component
window.dispatchEvent(new CustomEvent('open-template-form', { detail: template }));
}}
variant="secondary"
small
/>
<ActionBtn
key={`${template._id}-delete`}
icon={<TrashIcon className="w-4 h-4" />}
label="Excluir"
onClick={() => handleDelete(template._id)}
variant="secondary"
small
disabled={isPending || pendingDeleteId === template._id}
/>
</div>
</div>
</div>
))
)}
</div>
</Card>
);
}
@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import ActionBtn from "@/app/(protected)/dashboard/components/ActionBtn";
import ClassLinkCopyButton from "@/app/(protected)/components/student/ClassLinkCopyButton";
import Card from "@/app/(protected)/dashboard/components/Card";
import Stat from "@/app/(protected)/dashboard/components/Stat";
import MaterialsList from "@/app/(protected)/components/shared/MaterialsList";
import { normalizeStatus, getStatusLabel, getStatusColor } from "@/app/lib/helpers/statusPatterns";
import Label from "@/app/(protected)/components/shared/Label";
export default function GuardianClassDetail({studentId = "", clsData = {}, filesData = {}, categories = {}}) {
const normalizedStatus = normalizeStatus(clsData.status);
const cls = {
id: clsData.id || "",
classTitle: clsData.classTitle || "SEM TÍTULO",
teachers: clsData.teachers || ["SEM PROFESSOR"],
studentName: clsData.studentName || "Aluno",
status: clsData.status || "Ativa",
schedule: clsData.schedule || {days: ["Seg", "Qua"], time: "19:0020:30"},
stats: clsData.stats || {attendance: "100%", present: 0, late: 0, absent: 0, excused: 0, totalLessons: 0},
materials: filesData || {},
link: clsData?.link,
};
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<header className="border rounded-xl p-5 shadow-sm bg-white dark:bg-neutral-900 dark:border-neutral-800">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl md:text-2xl font-semibold text-neutral-900 dark:text-neutral-100 break-words">{cls.classTitle}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Aluno: {cls.studentName}
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400 break-words">
Professores: {Array.isArray(cls.teachers) ? cls.teachers.join(", ") : String(cls.teachers)} · {Array.isArray(cls.schedule?.days) ? cls.schedule.days.join(", ") : ""} · {cls.schedule?.time}
</p>
<div className="mt-5 flex gap-3">
<Link href={`/dashboard/guardian/${studentId}/class/${cls.id}/history`}>
<ActionBtn icon={<CalendarDaysIcon className="w-5 h-5" />} label="Histórico" />
</Link>
<ClassLinkCopyButton link={cls.link} />
</div>
</div>
<Label color={getStatusColor(normalizedStatus)}>
{getStatusLabel(normalizedStatus)}
</Label>
</div>
</header>
<section className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6">
<Card>
<h2 className="flex items-center gap-2 text-base font-semibold text-neutral-900 dark:text-neutral-100">Materiais da turma</h2>
<div className="mt-4 space-y-6">
<MaterialsList materials={cls.materials} categories={categories} emptyMessage="Ainda não há materiais disponíveis…" />
</div>
</Card>
</div>
<aside className="space-y-6">
<Card>
<h2 className="flex items-center gap-2 text-base font-semibold text-neutral-900 dark:text-neutral-100">Presenças do Aluno</h2>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Stat label="Presença" value={cls.stats.attendance}/>
<Stat label="Aulas" value={cls.stats.totalLessons || 0}/>
</div>
</Card>
</aside>
</section>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import {
CalendarIcon,
BookOpenIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
LinkIcon,
DocumentIcon,
} from "@heroicons/react/24/outline";
import Label from "@/app/(protected)/components/shared/Label";
import YouTubePlayer from "@/app/(protected)/components/lessons/YouTubePlayer";
import { isYouTubeUrl } from "@/app/lib/helpers/youtube";
export default function GuardianClassHistoryCard({ lesson }) {
const formatDateBR = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
};
const getAttendanceInfo = (attendance) => {
if (!attendance) return { label: "Sem registro", color: "gray" };
switch (attendance.status) {
case "present":
return { label: "Presente", color: "emerald" };
case "absent":
return { label: "Ausente", color: "red" };
case "late":
return { label: "Atrasado", color: "amber" };
case "excused":
return { label: "Justificado", color: "blue" };
default:
return { label: "Sem registro", color: "gray" };
}
};
const attendanceInfo = getAttendanceInfo(lesson.studentAttendance);
return (
<div className="border rounded-xl p-5 shadow-sm bg-white dark:bg-neutral-900 dark:border-neutral-800 hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 mb-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDateBR(lesson.date)}</span>
</div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{lesson.topic}
</h3>
{lesson.teacherId && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Professor: {lesson.teacherId.fullName}
</p>
)}
</div>
<Label color={attendanceInfo.color}>
{attendanceInfo.label}
</Label>
</div>
{lesson.description && (
<div className="mt-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<BookOpenIcon className="w-4 h-4" />
<span className="font-medium">Conteúdo da Aula</span>
</div>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{lesson.description}
</p>
</div>
)}
{lesson.notes && (
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>Observações:</strong> {lesson.notes}
</p>
</div>
)}
{lesson.studentAttendance?.notes && (
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Observações da presença:</strong> {lesson.studentAttendance.notes}
</p>
</div>
)}
{lesson.links && lesson.links.length > 0 && (
<div className="mt-3 space-y-2">
{lesson.links.map((link, idx) => (
isYouTubeUrl(link) ? (
<YouTubePlayer
key={idx}
url={link}
label={lesson.links.length > 1 ? `Vídeo da aula ${idx + 1}` : "Vídeo da aula"}
/>
) : (
<a
key={idx}
href={link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
>
<LinkIcon className="w-4 h-4" />
Abrir link da aula {lesson.links.length > 1 ? `${idx + 1}` : ''}
</a>
)
))}
</div>
)}
{!lesson.links?.length && lesson.link && (
<a
href={lesson.link}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
>
<LinkIcon className="w-4 h-4" />
Abrir link da aula
</a>
)}
{lesson.files && lesson.files.length > 0 && (
<div className="mt-3 space-y-1">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400">Materiais:</p>
<div className="flex flex-wrap gap-2">
{lesson.files.map((file, idx) => (
<a
key={idx}
href={file.url?.replace("proxy://", "/api/files/") || "#"}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 rounded-full hover:bg-indigo-100 dark:hover:bg-indigo-900/40 transition-colors"
>
<DocumentIcon className="w-3.5 h-3.5" />
{file.title || `Material ${idx + 1}`}
</a>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,19 @@
"use client";
import GuardianClassHistoryCard from "@/app/(protected)/components/guardian/GuardianClassHistoryCard";
import ClassHistoryList from "@/app/(protected)/components/shared/ClassHistoryList";
export default function GuardianClassHistoryList({studentId, classId, classTitle, lessons}) {
return (
<ClassHistoryList backHref={`/dashboard/guardian/${studentId}/class/${classId}`} classTitle={classTitle}>
{lessons && lessons.length > 0 ? (
lessons.map((lesson) => (
<GuardianClassHistoryCard
key={lesson._id}
lesson={lesson}
/>
))
) : null}
</ClassHistoryList>
);
}
@@ -0,0 +1,27 @@
"use client";
import {ClassCard} from "@/app/(protected)/components/shared/ClassCard";
export function WardClassCardComponent({classes, studentId}) {
const list = Array.isArray(classes) ? classes : classes ? [classes] : [];
if (!list.length) {
return (
<div className="text-gray-600 dark:text-gray-300 italic">
Nenhuma turma encontrada.
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{list.map((cls) => (
<ClassCard
key={String(cls?._id || `${cls?.classTitle}-${cls?.startDate}`)}
cls={cls}
href={studentId && cls?._id ? `/dashboard/guardian/${studentId}/class/${cls._id}` : undefined}
/>
))}
</div>
);
}
@@ -0,0 +1,453 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { XMarkIcon, PlusIcon, TrashIcon, ArrowUpTrayIcon, DocumentIcon } from "@heroicons/react/24/outline";
import { isYouTubeUrl, getYouTubeThumbnail } from "@/app/lib/helpers/youtube";
const MAX_FILE_SIZE = 500 * 1024 * 1024;
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
export default function LessonForm({
isOpen,
onClose,
classId,
students = [],
lesson = null,
onSave
}) {
const [attendance, setAttendance] = useState([]);
const [links, setLinks] = useState([""]);
const [pendingFiles, setPendingFiles] = useState([]);
const [existingFiles, setExistingFiles] = useState([]);
const [isPending, setIsPending] = useState(false);
const [submitError, setSubmitError] = useState("");
const dateRef = useRef(null);
const topicRef = useRef(null);
const notesRef = useRef(null);
const fileInputRef = useRef(null);
useEffect(() => {
// Normaliza cada entrada: a página de histórico popula attendance.studentId
// em um objeto { _id, fullName }; extraímos sempre o id em string para que
// o highlight e o envio ao backend funcionem corretamente.
const normalizeEntry = (a) => {
const sid =
a?.studentId?._id?.toString?.() ||
a?.studentId?.toString?.() ||
a?.studentId;
const status = a?.status;
return sid && status ? { studentId: String(sid), status } : null;
};
if (lesson?.attendance && lesson.attendance.length > 0) {
setAttendance(lesson.attendance.map(normalizeEntry).filter(Boolean));
} else if (students.length > 0) {
setAttendance(
students.map(student => ({
studentId: String(student._id),
status: 'present'
}))
);
}
}, [students, lesson]);
useEffect(() => {
if (lesson?.links && lesson.links.length > 0) {
setLinks(lesson.links);
} else if (lesson?.link) {
setLinks([lesson.link]);
} else {
setLinks([""]);
}
if (lesson?.files && lesson.files.length > 0) {
setExistingFiles(lesson.files);
} else {
setExistingFiles([]);
}
setPendingFiles([]);
}, [lesson]);
const handleAttendanceChange = (studentId, status) => {
const sid = String(studentId);
setAttendance(prev =>
prev.map(att =>
String(att.studentId) === sid ? { ...att, status } : att
)
);
};
const addLink = () => {
setLinks(prev => [...prev, ""]);
};
const removeLink = (index) => {
setLinks(prev => prev.filter((_, i) => i !== index));
};
const updateLink = (index, value) => {
setLinks(prev => prev.map((l, i) => i === index ? value : l));
};
const handleFileSelect = (e) => {
const files = Array.from(e.target.files || []);
const validFiles = files.filter(f => {
if (f.size > MAX_FILE_SIZE) {
setSubmitError(`Arquivo "${f.name}" excede o limite de 500MB.`);
return false;
}
return true;
});
setPendingFiles(prev => [...prev, ...validFiles]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index) => {
setPendingFiles(prev => prev.filter((_, i) => i !== index));
};
const removeExistingFile = (index) => {
setExistingFiles(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsPending(true);
setSubmitError("");
try {
const uploadedFileIds = [];
for (const file of pendingFiles) {
const fd = new FormData();
fd.append("file", file);
fd.append("title", file.name);
const uploadRes = await fetch("/api/files/upload", {
method: "POST",
body: fd,
});
if (!uploadRes.ok) {
const errData = await uploadRes.json().catch(() => ({}));
throw new Error(errData.message || `Erro ao enviar arquivo: ${file.name}`);
}
const uploadData = await uploadRes.json();
if (uploadData.file?._id) {
uploadedFileIds.push(uploadData.file._id);
}
}
const existingFileIds = existingFiles.map(f => f._id?.toString?.() || f.toString());
const allFileIds = [...existingFileIds, ...uploadedFileIds];
const validLinks = links.filter(l => l.trim() !== "");
const lessonData = {
date: dateRef.current.value,
topic: topicRef.current.value,
notes: notesRef.current.value,
links: validLinks,
fileIds: allFileIds,
attendance
};
const url = lesson
? `/api/classes/${classId}/lessons/${lesson._id}`
: `/api/classes/${classId}/lessons`;
const method = lesson ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(lessonData),
});
if (response.ok) {
const result = await response.json();
onSave(result);
onClose();
} else {
let errorMessage = "Não foi possível salvar a aula. Tente novamente.";
try {
const errorJson = await response.json();
errorMessage = errorJson?.message || errorJson?.error || errorMessage;
} catch {
const errorText = await response.text();
if (errorText) {
errorMessage = errorText;
}
}
if (response.status === 403 && !errorMessage) {
errorMessage = "Você não tem permissão para registrar aula nesta turma.";
}
setSubmitError(errorMessage);
}
} catch (error) {
console.error('Lesson save request failed:', error);
setSubmitError(error.message || "Erro de conexão ao salvar aula. Verifique sua conexão e tente novamente.");
} finally {
setIsPending(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-800">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{lesson ? 'Editar Aula' : 'Registrar Nova Aula'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{submitError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{submitError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Data da Aula *
</label>
<input
ref={dateRef}
type="date"
lang="pt-BR"
name="date"
required
defaultValue={lesson ? new Date(lesson.date).toISOString().split('T')[0] : new Date().toISOString().split('T')[0]}
className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tópico/Assunto *
</label>
<input
ref={topicRef}
type="text"
name="topic"
required
defaultValue={lesson?.topic || ''}
placeholder="Ex: Present Simple - Rotinas"
className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
Anotações
</label>
<textarea
ref={notesRef}
name="notes"
rows={3}
defaultValue={lesson?.notes || ''}
placeholder="Conteúdo abordado, exercícios realizados, etc."
className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Links da Aula
</label>
<button
type="button"
onClick={addLink}
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Adicionar link
</button>
</div>
<div className="space-y-2">
{links.map((link, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center gap-2">
<input
type="url"
value={link}
onChange={(e) => updateLink(index, e.target.value)}
placeholder="https://..."
className="flex-1 border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
/>
{links.length > 1 && (
<button
type="button"
onClick={() => removeLink(index)}
className="p-1.5 text-red-500 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
{isYouTubeUrl(link) && (
<div className="flex items-center gap-3 p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700">
<img
src={getYouTubeThumbnail(link)}
alt="Preview do vídeo"
className="w-20 h-12 object-cover rounded shrink-0"
loading="lazy"
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Vídeo do YouTube detectado será exibido com player embutido para o aluno.
</span>
</div>
)}
</div>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Materiais
</label>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
<ArrowUpTrayIcon className="w-4 h-4" />
Adicionar arquivo
</button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
<div className="space-y-2">
{existingFiles.map((file, index) => (
<div key={`existing-${index}`} className="flex items-center gap-2 p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<DocumentIcon className="w-5 h-5 text-indigo-500 shrink-0" />
<span className="flex-1 text-sm text-neutral-700 dark:text-neutral-300 truncate">
{file.title || file.name || "Arquivo"}
</span>
<span className="text-xs text-neutral-400">
{file.size ? formatFileSize(file.size) : ""}
</span>
<button
type="button"
onClick={() => removeExistingFile(index)}
className="p-1 text-red-500 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
{pendingFiles.map((file, index) => (
<div key={`pending-${index}`} className="flex items-center gap-2 p-2 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg">
<ArrowUpTrayIcon className="w-5 h-5 text-indigo-500 shrink-0" />
<span className="flex-1 text-sm text-indigo-700 dark:text-indigo-300 truncate">
{file.name}
</span>
<span className="text-xs text-indigo-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removePendingFile(index)}
className="p-1 text-red-500 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
{existingFiles.length === 0 && pendingFiles.length === 0 && (
<p className="text-sm text-neutral-400 dark:text-neutral-500">
Nenhum material adicionado
</p>
)}
</div>
</div>
<div>
<h3 className="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-4">
Lista de Presença
</h3>
<div className="space-y-2 max-h-60 overflow-y-auto">
{students.map((student) => {
const currentAttendance = attendance.find(a => String(a.studentId) === String(student._id));
return (
<div key={student._id} className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{student.fullName}
</span>
<div className="flex flex-wrap gap-2">
{[
{ value: 'present', label: 'Presente', color: 'bg-emerald-600 text-white border-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800' },
{ value: 'absent', label: 'Ausente', color: 'bg-red-600 text-white border-red-600 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800' },
{ value: 'late', label: 'Atrasado', color: 'bg-amber-600 text-white border-amber-600 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800' },
{ value: 'excused', label: 'Justificado', color: 'bg-blue-600 text-white border-blue-600 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleAttendanceChange(student._id, option.value)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${option.color} ${
currentAttendance?.status === option.value
? 'ring-2 ring-offset-1 ring-current'
: 'opacity-75 hover:opacity-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? 'Salvando...' : (lesson ? 'Atualizar' : 'Salvar')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { PlayIcon, ChevronUpIcon } from "@heroicons/react/24/solid";
import {
getYouTubeEmbedUrl,
getYouTubeThumbnail,
} from "@/app/lib/helpers/youtube";
export default function YouTubePlayer({ url, label = "Assistir vídeo da aula" }) {
const [expanded, setExpanded] = useState(false);
const embedUrl = getYouTubeEmbedUrl(url);
const thumbnail = getYouTubeThumbnail(url);
if (!embedUrl) return null;
if (expanded) {
return (
<div className="rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700 bg-black">
<div className="aspect-video w-full">
<iframe
src={embedUrl}
title={label}
className="w-full h-full"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
<button
type="button"
onClick={() => setExpanded(false)}
className="w-full flex items-center justify-center gap-1 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-900 hover:bg-neutral-800 transition-colors"
>
<ChevronUpIcon className="w-3.5 h-3.5" />
Recolher vídeo
</button>
</div>
);
}
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="group relative w-full rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700 block text-left"
>
<div className="relative aspect-video w-full bg-neutral-100 dark:bg-neutral-800">
{thumbnail && (
<img
src={thumbnail}
alt={label}
className="w-full h-full object-cover"
loading="lazy"
/>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
<span className="flex items-center justify-center w-12 h-12 rounded-full bg-red-600 group-hover:bg-red-500 group-hover:scale-110 transition-all shadow-lg">
<PlayIcon className="w-6 h-6 text-white ml-0.5" />
</span>
</div>
</div>
<div className="px-3 py-2 bg-white dark:bg-neutral-900 flex items-center gap-2">
<span className="flex-1 text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate">
{label}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500">
YouTube
</span>
</div>
</button>
);
}
@@ -0,0 +1,78 @@
import React from "react";
const buttonStyles = {
// Primary action (Adicionar, Criar, Salvar)
primary: `
bg-indigo-600 hover:bg-indigo-700
text-white font-semibold
py-2 px-4 rounded-lg
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
`,
// Secondary (Voltar, Cancelar)
secondary: `
border border-gray-300 dark:border-neutral-600
bg-white dark:bg-neutral-800
text-gray-700 dark:text-neutral-100
hover:bg-gray-50 dark:hover:bg-neutral-700
font-medium py-2 px-4 rounded-lg
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
`,
// Ghost (sem borda, menos prominente)
ghost: `
text-gray-600 dark:text-neutral-300
hover:bg-gray-100 dark:hover:bg-neutral-800
font-medium py-2 px-3 rounded-lg
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
`,
// Danger (Excluir, Remover)
danger: `
bg-red-600 hover:bg-red-700
text-white font-semibold
py-2 px-4 rounded-lg
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
`,
};
const sizeStyles = {
sm: "text-sm px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-5 py-2.5",
};
function Button({
children,
variant = "secondary",
size = "md",
className = "",
disabled = false,
type = "button",
icon: Icon,
iconPosition = "left",
...props
}) {
const baseClasses = "inline-flex items-center justify-center gap-2 font-medium cursor-pointer";
const variantClasses = buttonStyles[variant] || buttonStyles.secondary;
const sizeClasses = sizeStyles[size] || "";
return (
<button
type={type}
disabled={disabled}
className={`${baseClasses} ${variantClasses} ${sizeClasses} ${className}`.trim().replace(/\s+/g, " ")}
{...props}
>
{Icon && iconPosition === "left" && <Icon className="w-4 h-4" />}
{children}
{Icon && iconPosition === "right" && <Icon className="w-4 h-4" />}
</button>
);
}
export default Button;
@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { ClientDateComponent } from "@/app/(protected)/components/shared/ClientDateComponent";
export function ClassCard({ cls, href }) {
const formatDays = (days) => {
if (!Array.isArray(days) || !days.length) return "—";
return days.join(", ");
};
const statusColors = {
active: "badge-emerald",
inactive: "badge-blue",
};
const teachers = Array.isArray(cls?.teachers) ? cls.teachers : [];
const teacherNames = teachers
.map((t) => t?.fullName || t?.email)
.filter(Boolean)
.join(", ");
const status = cls?.status || "active";
const price = typeof cls?.price === "number" ? cls.price : null;
const link = href || "#";
return (
<Link href={link} className="block no-underline">
<div className="border rounded-xl p-5 shadow-sm bg-white dark:bg-neutral-900 dark:border-neutral-800 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{cls?.classTitle || "Turma"}
</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{teacherNames || "Professor não definido"}
</p>
</div>
<span
className={statusColors[status] || "badge-gray"}
title="Status da turma"
>
{status === 'active' ? 'ativa' : 'inativa'}
</span>
</div>
<div className="mt-4 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-neutral-700 dark:text-neutral-300 font-medium">Início</span>
<span className="text-neutral-900 dark:text-neutral-100">
<ClientDateComponent date={cls?.startDate} />
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-neutral-500 dark:text-neutral-400">Término</span>
<span className="text-neutral-800 dark:text-neutral-200">
<ClientDateComponent date={cls?.endDate} />
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-neutral-500 dark:text-neutral-400">Horário</span>
<span className="text-neutral-800 dark:text-neutral-200">
{cls?.schedule?.time || "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-neutral-500 dark:text-neutral-400">Dias</span>
<span className="text-neutral-800 dark:text-neutral-200 text-right">
{formatDays(cls?.schedule?.days)}
</span>
</div>
{price !== null && (
<div className="flex items-center justify-between">
<span className="text-neutral-500 dark:text-neutral-400">Mensalidade</span>
<span className="text-neutral-800 dark:text-neutral-200">
R$ {price.toFixed(2)}
</span>
</div>
)}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,27 @@
"use client";
import {ClassCard} from "@/app/(protected)/components/shared/ClassCard";
export function ClassCardComponent({classes, hrefBase}) {
const list = Array.isArray(classes) ? classes : classes ? [classes] : [];
if (!list.length) {
return (
<div className="text-gray-600 dark:text-gray-300 italic">
Nenhuma turma encontrada.
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{list.map((cls) => (
<ClassCard
key={String(cls?._id || `${cls?.classTitle}-${cls?.startDate}`)}
cls={cls}
href={hrefBase && cls?._id ? `${hrefBase}/${cls._id}` : undefined}
/>
))}
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
export default function ClassHistoryList({
backHref,
backLabel = "Voltar para a turma",
classTitle,
children,
emptyMessage = "Nenhuma aula registrada ainda.",
}) {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<div className="mb-6">
<Link
href={backHref}
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors mb-4"
>
<ArrowLeftIcon className="w-4 h-4" />
{backLabel}
</Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
Histórico de Aulas
</h1>
{classTitle && (
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{classTitle}
</p>
)}
</div>
<div className="space-y-4">
{children || (
<div className="text-center py-12 border rounded-xl bg-neutral-50 dark:bg-neutral-900 dark:border-neutral-800">
<p className="text-neutral-600 dark:text-neutral-400">
{emptyMessage}
</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState, useEffect } from 'react';
export function ClientDateComponent({ date, className = "" }) {
const [formattedDate, setFormattedDate] = useState("—");
useEffect(() => {
if (!date) {
setFormattedDate("—");
return;
}
try {
const dateObj = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date;
setFormattedDate(dateObj.toLocaleDateString('pt-BR', {
year: "numeric",
month: "short",
day: "2-digit",
}));
} catch {
setFormattedDate("—");
}
}, [date]);
return (
<span className={className}>
{formattedDate}
</span>
);
}
@@ -0,0 +1,59 @@
import Link from "next/link";
const BTN = {
indigo: "bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600",
emerald: "bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-500 dark:hover:bg-emerald-600",
purple: "bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600",
blue: "bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
sky: "bg-sky-600 hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600",
red: "bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600",
amber: "bg-amber-500 hover:bg-amber-600 dark:bg-amber-400 dark:hover:bg-amber-500 text-black dark:text-black",
orange: "bg-orange-600 hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-600",
pink: "bg-pink-600 hover:bg-pink-700 dark:bg-pink-500 dark:hover:bg-pink-600",
rose: "bg-rose-600 hover:bg-rose-700 dark:bg-rose-500 dark:hover:bg-rose-600",
cyan: "bg-cyan-600 hover:bg-cyan-700 dark:bg-cyan-500 dark:hover:bg-cyan-600",
teal: "bg-teal-600 hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600",
lime: "bg-lime-600 hover:bg-lime-700 dark:bg-lime-400 dark:hover:bg-lime-500 text-black dark:text-black",
};
const ICON_BG = {
indigo: "bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400",
emerald: "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
purple: "bg-purple-100 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400",
blue: "bg-blue-100 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400",
sky: "bg-sky-100 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400",
red: "bg-red-100 text-red-600 dark:bg-red-500/15 dark:text-red-400",
amber: "bg-amber-100 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
orange: "bg-orange-100 text-orange-600 dark:bg-orange-500/15 dark:text-orange-400",
pink: "bg-pink-100 text-pink-600 dark:bg-pink-500/15 dark:text-pink-400",
rose: "bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
cyan: "bg-cyan-100 text-cyan-600 dark:bg-cyan-500/15 dark:text-cyan-400",
teal: "bg-teal-100 text-teal-600 dark:bg-teal-500/15 dark:text-teal-400",
lime: "bg-lime-100 text-lime-600 dark:bg-lime-500/15 dark:text-lime-400",
};
export default function DashboardCard({ title, description, buttonText, buttonColor = "indigo", link, icon: Icon }) {
const btn = BTN[buttonColor] ?? BTN.indigo;
const iconBg = ICON_BG[buttonColor] ?? ICON_BG.indigo;
return (
<Link href={link} className="block group">
<div className="h-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-neutral-900 p-6 shadow-sm hover:shadow-lg hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200">
<div className="flex items-start gap-4">
{Icon && (
<div className={`p-3 rounded-xl ${iconBg} shrink-0`}>
<Icon className="w-6 h-6" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">{description}</p>
<span className={`inline-flex justify-center text-white text-sm font-medium py-2 px-6 rounded-lg transition-all duration-200 ${btn}`}>
{buttonText}
</span>
</div>
</div>
</div>
</Link>
);
}
@@ -0,0 +1,57 @@
"use client";
import React from 'react';
function FlashMessage({ message, type = 'info' }) {
if (!message) {
return null;
}
let baseClasses = "px-6 py-4 rounded-lg relative mb-4 shadow-lg text-sm font-medium";
let textClasses = "";
let bgClasses = "";
let borderClasses = "";
let icon = null;
switch (type) {
case 'success':
textClasses = "text-white dark:text-emerald-300";
bgClasses = "bg-emerald-600 dark:bg-emerald-900/20";
borderClasses = "border border-emerald-600 dark:border-emerald-800";
// icon = <CheckCircleIcon className="h-5 w-5 mr-2" />; // Exemplo com Heroicons
break;
case 'error':
textClasses = "text-white dark:text-red-300";
bgClasses = "bg-red-600 dark:bg-red-900/20";
borderClasses = "border border-red-600 dark:border-red-800";
// icon = <XCircleIcon className="h-5 w-5 mr-2" />; // Exemplo com Heroicons
break;
case 'warning':
textClasses = "text-white dark:text-amber-300";
bgClasses = "bg-amber-600 dark:bg-amber-900/20";
borderClasses = "border border-amber-600 dark:border-amber-800";
// icon = <ExclamationTriangleIcon className="h-5 w-5 mr-2" />; // Exemplo com Heroicons
break;
case 'info':
default:
textClasses = "text-white dark:text-blue-300";
bgClasses = "bg-blue-600 dark:bg-blue-900/20";
borderClasses = "border border-blue-600 dark:border-blue-800";
// icon = <InformationCircleIcon className="h-5 w-5 mr-2" />; // Exemplo com Heroicons
break;
}
return (
<div className={`${baseClasses} ${textClasses} ${bgClasses} ${borderClasses}`} role="alert">
<div className="flex items-center">
{icon}
<strong className="font-bold mr-1">
{type.charAt(0).toUpperCase() + type.slice(1)}:
</strong>
<span className="block sm:inline">{message}</span>
</div>
</div>
);
}
export default FlashMessage;
@@ -0,0 +1,32 @@
export default function FormField({
label,
id,
error,
helper,
required = false,
children,
className = "",
}) {
return (
<div className={className}>
{label && (
<label
htmlFor={id}
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{children}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{helper && !error && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{helper}
</p>
)}
</div>
);
}
@@ -0,0 +1,8 @@
import React from "react";
export default function Input({ className = "", type = "text", ...props }) {
const baseClass =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
return <input type={type} className={`${baseClass} ${className}`.trim()} {...props} />;
}
@@ -0,0 +1,55 @@
"use client";
/**
* Label Component - Badge reutilizável para status e categorias
*
* @param {ReactNode} children - Texto do label
* @param {string} color - Cor do label (gray, red, amber, emerald, blue, sky, indigo, purple, pink)
* @param {string} size - Tamanho (sm, md, lg)
* @param {boolean} uppercase - Se true, transforma texto em uppercase
* @param {function} onRemove - Função chamada ao clicar no X (para tags removíveis)
* @param {string} className - Classes adicionais
*
* @example
* <Label color="emerald">Ativo</Label>
* <Label color="amber" uppercase>Pendente</Label>
* <Label color="red" size="sm">Erro</Label>
* <Label color="blue" onRemove={() => console.log('removido')}>Tag removível</Label>
*/
export default function Label({ children, color = "gray", size = "md", uppercase = false, onRemove, className = "" }) {
const colorClasses = {
gray: "bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700",
red: "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-500/30",
amber: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-500/30",
emerald: "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-500/30",
blue: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-500/30",
indigo: "bg-indigo-100 text-indigo-800 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-500/30",
purple: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-500/30",
};
const labelClass = colorClasses[color] || colorClasses.gray;
const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-xs",
lg: "px-3 py-1.5 text-sm",
};
const sizeClass = sizeClasses[size] || sizeClasses.md;
const uppercaseClass = uppercase ? "uppercase tracking-wider" : "";
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border transition-colors font-medium ${labelClass} ${sizeClass} ${uppercaseClass} ${className}`}>
<span>{uppercase ? String(children).toUpperCase() : children}</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="text-current/70 hover:text-current transition-colors"
>
×
</button>
)}
</span>
);
}
@@ -0,0 +1,22 @@
import React from "react";
import PropTypes from "prop-types";
function MainSection({ children, className = "", ...props }) {
return (
<main
className={`flex-1 px-4 sm:px-6 lg:px-8 py-4 sm:py-6 overflow-y-auto ${className}`}
{...props}
>
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
);
}
MainSection.propTypes = {
children: PropTypes.node,
className: PropTypes.string
};
export default MainSection;
@@ -0,0 +1,61 @@
import { FolderOpenIcon } from "@heroicons/react/24/outline";
import { getCategoryColorClasses } from "@/app/lib/helpers/categoryColors";
export default function MaterialsList({
materials = {},
categories = {},
emptyMessage = "Não há materiais disponíveis...",
renderActions,
}) {
if (Object.keys(materials).length === 0) {
return <p className="text-sm">{emptyMessage}</p>;
}
return Object.entries(materials).map(([category, files]) => {
const colorIndex = categories[category]?.colorIndex || 0;
const colorClass = getCategoryColorClasses(colorIndex);
return (
<div key={category} className="first:pt-0">
<div
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ${colorClass} mb-3`}
>
<FolderOpenIcon className="w-4 h-4" />
{category}
</div>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{files.map((m, idx) => (
<li
key={idx}
className="py-3 flex items-center justify-between gap-3"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-800 dark:text-neutral-200 truncate">
{m.name}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{m.size
? `${(m.size / 1024).toFixed(1)} KB`
: "Tamanho desconhecido"}
</p>
</div>
{renderActions ? (
renderActions(m)
) : (
<a
className="text-sm px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-200 transition whitespace-nowrap"
href={m.url}
download
target="_blank"
rel="noopener noreferrer"
>
Baixar
</a>
)}
</li>
))}
</ul>
</div>
);
});
}
@@ -0,0 +1,93 @@
"use client";
import React, { useEffect, useRef } from "react";
import { XMarkIcon } from "@heroicons/react/24/outline";
export default function Modal({
open,
onClose,
title,
children,
className = "",
maxWidth = "md",
footer,
}) {
const overlayRef = useRef(null);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
const handleEsc = (e) => {
if (e.key === "Escape" && onClose) onClose();
};
if (open) {
window.addEventListener("keydown", handleEsc);
}
return () => window.removeEventListener("keydown", handleEsc);
}, [open, onClose]);
if (!open) return null;
const maxWidthClasses = {
sm: "sm:max-w-md",
md: "sm:max-w-2xl md:max-w-4xl",
lg: "sm:max-w-4xl md:max-w-6xl",
full: "sm:max-w-[95vw]",
};
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={`relative z-10 w-full ${
maxWidthClasses[maxWidth] || maxWidthClasses.md
} max-h-[90vh] overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-xl flex flex-col ${className}`}
>
{title && (
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 dark:border-neutral-800 shrink-0">
<div>
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h2>
</div>
{onClose && (
<button
type="button"
onClick={onClose}
className="p-1 rounded-lg text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label="Fechar"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
)}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</div>
{footer && (
<div className="flex justify-end gap-3 p-4 sm:p-6 border-t border-neutral-200 dark:border-neutral-800 shrink-0">
{footer}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
function PageHeader({ title, subtitle, actions, icon, className = "" }) {
return (
<div className={`relative mb-6 md:flex md:items-center md:justify-between ${className}`}>
<div className="page-header md:flex md:items-center md:justify-between w-full rounded-xl border border-gray-200 border-l-4 border-l-indigo-600 shadow-sm dark:border-gray-700 dark:border-l-indigo-400 dark:shadow-none">
<div className="min-w-0 flex-1 p-5">
<div className="flex items-center gap-3">
{icon && (
<span className="text-xl text-indigo-600 dark:text-indigo-400">
{icon}
</span>
)}
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h1>
{subtitle && (
<p className="mt-1 text-sm text-neutral-400 dark:text-neutral-300 max-w-2xl">
{subtitle}
</p>
)}
</div>
</div>
</div>
{actions && (
<div className="mt-4 flex flex-wrap p-5 md:ml-4 md:mt-0 gap-2 items-center">
{actions}
</div>
)}
</div>
</div>
);
}
export default PageHeader;
@@ -0,0 +1,15 @@
import React from "react";
function PageSectionTitle({ title, text, className = "" }) {
const content = text || title;
return (
<div className={`border-b border-gray-200 dark:border-neutral-700 pb-4 mb-6 ${className}`}>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{content}
</h2>
</div>
);
}
export default PageSectionTitle;
@@ -0,0 +1,19 @@
import React from "react";
function PageTitle({ title, subTitle = "", className = "" }) {
return (
<div className={`${className}`}>
<h1 className="text-2xl md:text-3xl font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
{!!subTitle && (
<p className="mt-2 text-neutral-700 dark:text-neutral-300">
{subTitle}
</p>
)}
</div>
);
}
export default PageTitle;
@@ -0,0 +1,21 @@
import Label from "@/app/(protected)/components/shared/Label";
import { getStatusLabel, getStatusColor, STATUS } from "@/app/lib/helpers/statusPatterns";
export default function PaymentStatusBadge({ status }) {
// Map payment status to STATUS constants
const statusMapping = {
pending: STATUS.PENDING,
pending_verification: STATUS.PENDING_VERIFICATION,
verified: STATUS.VERIFIED,
rejected: STATUS.REJECTED,
not_paid: STATUS.NOT_PAID,
};
const normalizedStatus = statusMapping[status] || STATUS.NOT_PAID;
return (
<Label color={getStatusColor(normalizedStatus)}>
{getStatusLabel(normalizedStatus)}
</Label>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
const FALLBACK_GRADIENT = "from-indigo-100 to-purple-100 dark:from-indigo-900/30 dark:to-purple-900/30";
const FALLBACK_ICON_COLOR = "text-indigo-400";
function FallbackIcon({ size }) {
const iconSize = size === "sm" ? "w-5 h-5" : size === "lg" ? "w-12 h-12" : "w-6 h-6";
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={`${iconSize} ${FALLBACK_ICON_COLOR}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
);
}
export default function ProductImage({ src, alt, className = "", size = "md" }) {
const [error, setError] = useState(false);
if (!src || error) {
return (
<div
className={`${className} bg-gradient-to-br ${FALLBACK_GRADIENT} flex items-center justify-center`}
>
<FallbackIcon size={size} />
</div>
);
}
return (
<img
src={src}
alt={alt || "Produto"}
className={className}
onError={() => setError(true)}
/>
);
}
@@ -0,0 +1,78 @@
"use client";
/**
* RoleCheckbox Component - Checkbox colorido para seleção de funções/roles
*
* @param {string} value - Valor do checkbox
* @param {string} label - Texto exibido
* @param {string} color - Cor (emerald, blue, amber, red, indigo, purple)
* @param {boolean} checked - Se está selecionado
* @param {function} onChange - Handler para mudança
* @param {string} className - Classes adicionais
*
* @example
* <RoleCheckbox
* value="student"
* label="Estudante"
* color="emerald"
* checked={isChecked}
* onChange={onRoleChange("student")}
* />
*/
export default function RoleCheckbox({ value, label, color = "gray", checked = false, onChange, className = "" }) {
const colorClasses = {
gray: {
unchecked: "bg-white border-slate-300 text-slate-700 hover:border-slate-400 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-300",
checked: "bg-slate-200 border-slate-400 text-slate-900 dark:bg-zinc-700 dark:border-zinc-500 dark:text-zinc-100",
checkbox: "text-neutral-600 dark:text-neutral-400 focus:ring-neutral-500 dark:focus:ring-neutral-500",
},
emerald: {
unchecked: "bg-white border-emerald-300 text-emerald-700 hover:border-emerald-400 dark:bg-zinc-800 dark:border-emerald-800 dark:text-emerald-300",
checked: "bg-emerald-100 border-emerald-500 text-emerald-900 dark:bg-emerald-900/30 dark:border-emerald-500 dark:text-emerald-200",
checkbox: "text-emerald-600 dark:text-emerald-400 focus:ring-emerald-500 dark:focus:ring-emerald-500",
},
blue: {
unchecked: "bg-white border-blue-300 text-blue-700 hover:border-blue-400 dark:bg-zinc-800 dark:border-blue-800 dark:text-blue-300",
checked: "bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/30 dark:border-blue-500 dark:text-blue-200",
checkbox: "text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-500",
},
amber: {
unchecked: "bg-white border-amber-300 text-amber-700 hover:border-amber-400 dark:bg-zinc-800 dark:border-amber-800 dark:text-amber-300",
checked: "bg-amber-100 border-amber-500 text-amber-900 dark:bg-amber-900/30 dark:border-amber-500 dark:text-amber-200",
checkbox: "text-amber-600 dark:text-amber-400 focus:ring-amber-500 dark:focus:ring-amber-500",
},
red: {
unchecked: "bg-white border-red-300 text-red-700 hover:border-red-400 dark:bg-zinc-800 dark:border-red-800 dark:text-red-300",
checked: "bg-red-100 border-red-500 text-red-900 dark:bg-red-900/30 dark:border-red-500 dark:text-red-200",
checkbox: "text-red-600 dark:text-red-400 focus:ring-red-500 dark:focus:ring-red-500",
},
indigo: {
unchecked: "bg-white border-indigo-300 text-indigo-700 hover:border-indigo-400 dark:bg-zinc-800 dark:border-indigo-800 dark:text-indigo-300",
checked: "bg-indigo-100 border-indigo-500 text-indigo-900 dark:bg-indigo-900/30 dark:border-indigo-500 dark:text-indigo-200",
checkbox: "text-indigo-600 dark:text-indigo-400 focus:ring-indigo-500 dark:focus:ring-indigo-500",
},
purple: {
unchecked: "bg-white border-purple-300 text-purple-700 hover:border-purple-400 dark:bg-zinc-800 dark:border-purple-800 dark:text-purple-300",
checked: "bg-purple-100 border-purple-500 text-purple-900 dark:bg-purple-900/30 dark:border-purple-500 dark:text-purple-200",
checkbox: "text-purple-600 dark:text-purple-400 focus:ring-purple-500 dark:focus:ring-purple-500",
},
};
const classes = colorClasses[color] || colorClasses.gray;
const baseClasses = "inline-flex items-center gap-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors";
return (
<label className={`${baseClasses} ${checked ? classes.checked : classes.unchecked} ${className}`}>
<input
type="checkbox"
className={`w-4 h-4 rounded ${classes.checkbox}`}
checked={checked}
onChange={onChange}
value={value}
/>
<span className="text-sm font-medium select-none">
{label}
</span>
</label>
);
}
@@ -0,0 +1,12 @@
import React from "react";
export default function Select({ className = "", children, ...props }) {
const baseClass =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
return (
<select className={`${baseClass} ${className}`.trim()} {...props}>
{children}
</select>
);
}

Some files were not shown because too many files have changed in this diff Show More