Initial commit
This commit is contained in:
@@ -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 /> */}
|
||||
SÓ 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 já 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>
|
||||
);
|
||||
}
|
||||
@@ -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:00–20: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
Reference in New Issue
Block a user