Initial Monorepo Push: EduManager + Portal do Aluno (Self-Hosted)

This commit is contained in:
Sidney 2026-04-19 15:42:28 -03:00
commit 6b2522f038
94 changed files with 34771 additions and 0 deletions

160
docker-compose.yml Normal file
View File

@ -0,0 +1,160 @@
version: '3.8'
services:
# ============================
# BANCO DE DADOS PRINCIPAL
# ============================
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: edumanager
POSTGRES_USER: edumanager
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-EduManager2026!Seguro}
volumes:
- pgdata:/var/lib/postgresql/data
- ./schema_selfhosted.sql:/docker-entrypoint-initdb.d/01_schema.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U edumanager"]
interval: 10s
timeout: 5s
retries: 5
networks:
- edumanager-internal
# ============================
# STORAGE S3-COMPATIBLE (MINIO)
# ============================
minio:
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
volumes:
- miniodata:/data
ports:
- "9000:9000" # API S3
- "9001:9001" # Console Web
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
networks:
- edumanager-internal
# Cria os buckets automaticamente na primeira vez
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro};
mc mb --ignore-existing local/fotos-alunos;
mc mb --ignore-existing local/documentos;
mc mb --ignore-existing local/atestados;
mc mb --ignore-existing local/logos;
mc mb --ignore-existing local/exames;
mc mb --ignore-existing local/carnes;
mc anonymous set download local/fotos-alunos;
mc anonymous set download local/documentos;
mc anonymous set download local/logos;
mc anonymous set download local/exames;
mc anonymous set download local/carnes;
echo '✅ Buckets criados com sucesso!';
"
networks:
- edumanager-internal
# ============================
# EDUMANAGER (PAINEL ADMIN)
# ============================
edumanager:
build: .
restart: always
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://edumanager:${POSTGRES_PASSWORD:-EduManager2026!Seguro}@postgres:5432/edumanager
- JWT_SECRET=${JWT_SECRET:-EduManager-JWT-Secret-2026!}
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
- MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL:-http://localhost:9000}
- ASAAS_API_KEY=${ASAAS_API_KEY}
- ASAAS_API_URL=${ASAAS_API_URL}
- ASAAS_WEBHOOK_TOKEN=${ASAAS_WEBHOOK_TOKEN}
networks:
- edumanager-internal
- traefik-public
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.edumanager.rule=Host(`edumanager.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edumanager.entrypoints=websecure"
- "traefik.http.routers.edumanager.tls.certresolver=letsencrypt"
- "traefik.http.services.edumanager.loadbalancer.server.port=3000"
# ============================
# PORTAL DO ALUNO
# ============================
portalaluno:
build:
context: ../portalaluno
dockerfile: Dockerfile
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=postgresql://edumanager:${POSTGRES_PASSWORD:-EduManager2026!Seguro}@postgres:5432/edumanager
- JWT_SECRET=${JWT_SECRET:-EduManager-JWT-Secret-2026!}
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
- MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL:-http://localhost:9000}
networks:
- edumanager-internal
- traefik-public
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.portalaluno.rule=Host(`portal.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.portalaluno.entrypoints=websecure"
- "traefik.http.routers.portalaluno.tls.certresolver=letsencrypt"
- "traefik.http.services.portalaluno.loadbalancer.server.port=3001"
volumes:
pgdata:
driver: local
miniodata:
driver: local
networks:
edumanager-internal:
driver: overlay
traefik-public:
external: true

8
manager/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
!.env.example

4
manager/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_SUPABASE_URL=your_supabase_project_url
VITE_SUPABASE_KEY=your_supabase_anon_key
ASAAS_API_KEY=your_asaas_api_key
ASAAS_WEBHOOK_TOKEN=your_asaas_webhook_token

24
manager/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

125
manager/DialogContext.tsx Normal file
View File

@ -0,0 +1,125 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-react';
type DialogType = 'alert' | 'confirm' | 'success' | 'error' | 'warning' | 'info';
interface DialogOptions {
title: string;
message: string;
type?: DialogType;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
onCancel?: () => void;
}
interface DialogContextType {
showAlert: (title: string, message: string, type?: DialogType) => void;
showConfirm: (title: string, message: string, onConfirm: () => void, type?: DialogType) => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
export const useDialog = () => {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a DialogProvider');
}
return context;
};
export const DialogProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<DialogOptions | null>(null);
const [isClosing, setIsClosing] = useState(false);
const closeDialog = () => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setOptions(null);
setIsClosing(false);
}, 400);
};
const showAlert = (title: string, message: string, type: DialogType = 'info') => {
setOptions({ title, message, type, confirmLabel: 'OK' });
setIsOpen(true);
};
const showConfirm = (title: string, message: string, onConfirm: () => void, type: DialogType = 'warning') => {
setOptions({
title,
message,
type,
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar',
onConfirm: () => {
onConfirm();
closeDialog();
},
onCancel: closeDialog
});
setIsOpen(true);
};
const getIcon = (type?: DialogType) => {
switch (type) {
case 'success': return <CheckCircle className="text-emerald-500" size={24} />;
case 'error': return <XCircle className="text-red-500" size={24} />;
case 'warning': return <AlertTriangle className="text-amber-500" size={24} />;
case 'alert': return <AlertTriangle className="text-red-500" size={24} />;
default: return <Info className="text-indigo-500" size={24} />;
}
};
const getIconBg = (type?: DialogType) => {
switch (type) {
case 'success': return 'bg-emerald-100';
case 'error': return 'bg-red-100';
case 'warning': return 'bg-amber-100';
case 'alert': return 'bg-red-100';
default: return 'bg-indigo-100';
}
};
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
{children}
{isOpen && options && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-[100] transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-6 text-center">
<div className={`w-12 h-12 ${getIconBg(options.type)} rounded-full flex items-center justify-center mx-auto mb-4`}>
{getIcon(options.type)}
</div>
<h3 className="text-lg font-black text-slate-800 mb-2">{options.title}</h3>
<p className="text-sm text-slate-500 mb-6 whitespace-pre-wrap">{options.message}</p>
<div className="flex gap-3">
{options.cancelLabel && (
<button
onClick={options.onCancel}
className="flex-1 py-3 border border-slate-200 rounded-xl font-bold text-slate-600 hover:bg-slate-50 transition-colors"
>
{options.cancelLabel}
</button>
)}
<button
onClick={options.onConfirm || closeDialog}
className={`flex-1 py-3 text-white rounded-xl font-bold transition-all shadow-lg ${
options.type === 'error' || options.type === 'alert' || options.type === 'warning'
? 'bg-red-600 hover:bg-red-700 shadow-red-100'
: 'bg-indigo-600 hover:bg-indigo-700 shadow-indigo-100'
}`}
>
{options.confirmLabel}
</button>
</div>
</div>
</div>
</div>
)}
</DialogContext.Provider>
);
};

35
manager/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
# Copiar package files e instalar dependências
COPY package.json package-lock.json ./
RUN npm ci
# Copiar todo o código fonte
COPY . .
# Build da aplicação (gera a pasta /app/dist)
RUN npm run build
# ---- Production Stage ----
FROM node:22-alpine AS production
WORKDIR /app
# Copiar package files e instalar apenas dependências de produção
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copiar o servidor Express
COPY server.js ./
# Copiar a pasta dist gerada no build
COPY --from=builder /app/dist ./dist
# Expor a porta do servidor
EXPOSE 3000
# Comando para iniciar o servidor em modo produção
CMD ["node", "server.js"]

43
manager/README.md Normal file
View File

@ -0,0 +1,43 @@
# EduManager - Sistema de Gestão Escolar
Este é um sistema de gestão escolar desenvolvido com React, TypeScript e Vite.
## Como fazer o deploy no Netlify
1. **Baixe o código**: Faça o download de todos os arquivos deste projeto.
2. **Crie um repositório Git**: Inicie um repositório Git local e faça o commit dos arquivos.
```bash
git init
git add .
git commit -m "Initial commit"
```
3. **Envie para o GitHub/GitLab/Bitbucket**: Crie um repositório remoto e envie seu código.
4. **Conecte ao Netlify**:
* Acesse [netlify.com](https://www.netlify.com/).
* Clique em "Add new site" -> "Import an existing project".
* Selecione seu provedor Git e o repositório.
5. **Configurações de Build**:
* O Netlify deve detectar automaticamente as configurações do arquivo `netlify.toml`.
* **Build command**: `npm run build`
* **Publish directory**: `dist`
6. **Variáveis de Ambiente**:
* No painel do Netlify, vá em **Site settings > Environment variables**.
* Adicione as variáveis do Supabase (se estiver usando):
* `VITE_SUPABASE_URL`: Sua URL do projeto Supabase.
* `VITE_SUPABASE_KEY`: Sua chave pública (anon key) do Supabase.
7. **Deploy**: Clique em "Deploy site".
## Funcionalidades
* Cadastro de Alunos e Turmas
* Gestão Financeira
* Geração de Contratos em PDF
* Dashboard com Gráficos
* Backup Local e na Nuvem (Supabase)
## Desenvolvimento Local
Para rodar o projeto localmente:
1. Instale as dependências: `npm install`
2. Rode o servidor de desenvolvimento: `npm run dev`

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { SchoolData } from '../types';
import { geminiService } from '../services/geminiService';
import { Send, Sparkles, User, Bot, Loader2 } from 'lucide-react';
interface AIHelperProps {
data: SchoolData;
}
const AIHelper: React.FC<AIHelperProps> = ({ data }) => {
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<{ role: 'user' | 'bot'; content: string }[]>([
{ role: 'bot', content: 'Olá! Sou seu assistente de IA. Posso ajudar a gerar contratos, analisar a saúde financeira da escola ou criar relatórios. O que você precisa hoje?' }
]);
const handleAsk = async (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim() || isLoading) return;
const userMsg = prompt;
setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
setPrompt('');
setIsLoading(true);
const response = await geminiService.getAIAnalysis(userMsg, data);
setMessages(prev => [...prev, { role: 'bot', content: response }]);
setIsLoading(false);
};
const quickActions = [
"Resumo da situação financeira",
"Template de contrato de matrícula",
"Sugestão de cursos em alta",
"Alunos com mensalidades atrasadas"
];
return (
<div className="flex flex-col h-[calc(100vh-120px)] space-y-6 animate-in fade-in duration-300">
<header>
<h2 className="text-3xl font-extrabold text-slate-800 flex items-center gap-3 tracking-tight">
<Sparkles className="text-indigo-600" /> Assistente IA
</h2>
<p className="text-slate-500 font-medium">Insights inteligentes para otimizar sua gestão.</p>
</header>
<div className="flex-1 bg-white rounded-[2.5rem] border border-slate-200 shadow-2xl flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-8 space-y-6 bg-slate-50/20">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''} animate-in slide-in-from-bottom-2 duration-300`}>
<div className={`p-5 rounded-[2rem] max-w-[85%] shadow-sm ${
msg.role === 'user'
? 'bg-indigo-600 text-white rounded-tr-none'
: 'bg-white text-slate-800 rounded-tl-none border border-slate-100'
}`}>
<div className={`flex items-center gap-2 mb-2 text-[10px] font-black uppercase tracking-[0.2em] ${msg.role === 'user' ? 'text-indigo-200' : 'text-slate-400'}`}>
{msg.role === 'user' ? <><User size={12}/> Você</> : <><Bot size={12}/> EduManager AI</>}
</div>
<div className="text-sm whitespace-pre-wrap leading-relaxed font-medium">
{msg.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-4 animate-pulse">
<div className="bg-white border border-slate-100 p-5 rounded-[2rem] rounded-tl-none text-slate-400 shadow-sm flex items-center gap-3">
<Loader2 className="animate-spin text-indigo-500" size={20} />
<span className="text-xs font-bold uppercase tracking-wider">Analisando dados...</span>
</div>
</div>
)}
</div>
<div className="p-8 bg-white border-t border-slate-100">
<div className="flex flex-wrap gap-2 mb-5">
{quickActions.map((action, i) => (
<button
key={i}
onClick={() => setPrompt(action)}
className="text-[11px] font-bold bg-slate-50 border border-slate-200 px-4 py-2 rounded-2xl text-slate-600 hover:border-indigo-400 hover:text-indigo-600 transition-all hover:shadow-md hover:-translate-y-0.5"
>
{action}
</button>
))}
</div>
<form onSubmit={handleAsk} className="relative group">
<input
type="text"
placeholder="Digite sua dúvida ou solicitação aqui..."
className="w-full pl-6 pr-16 py-5 bg-white text-black border-2 border-slate-200 rounded-[2rem] focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 shadow-lg transition-all text-sm font-medium"
value={prompt}
onChange={e => setPrompt(e.target.value)}
/>
<button
disabled={isLoading}
className="absolute right-3 top-1/2 -translate-y-1/2 p-4 bg-indigo-600 text-white rounded-[1.5rem] hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg active:scale-95 group-hover:scale-105"
>
<Send size={20} />
</button>
</form>
</div>
</div>
</div>
);
};
export default AIHelper;

View File

@ -0,0 +1,281 @@
import React, { useState, useEffect, useRef } from 'react';
import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip } from 'lucide-react';
import { SchoolData, Notification, View } from '../types';
import { dbService } from '../services/dbService';
interface Props {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
setView: (view: View) => void;
onNavigateToStudent?: (studentId: string) => void;
}
const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavigateToStudent }) => {
const [isOpen, setIsOpen] = useState(false);
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
const [notifWithAttachment, setNotifWithAttachment] = useState<Notification | null>(null);
const prevCountRef = useRef<number>(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const handleDeleteAttachment = () => {
if (!notifWithAttachment) return;
const updatedNotifs = (data.notifications || []).map(n =>
n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n
);
updateData({ notifications: updatedNotifs });
dbService.saveData({ ...data, notifications: updatedNotifs });
setViewingAttachment(null);
setNotifWithAttachment(null);
};
const adminNotifs = (data.notifications || []).filter(n => n.studentId === 'admin').sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const unreadCount = adminNotifs.filter(n => !n.read).length;
// Som de notificação quando chega uma nova
useEffect(() => {
if (unreadCount > prevCountRef.current && prevCountRef.current >= 0) {
try {
if (!audioRef.current) {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.2);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
}
} catch(e) {
console.warn('Som de notificação indisponível', e);
}
}
prevCountRef.current = unreadCount;
}, [unreadCount]);
const handleAction = (notif: Notification) => {
if (!notif.read) handleMarkAsRead(notif.id);
if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) {
if (onNavigateToStudent) {
onNavigateToStudent(notif.studentId);
} else {
setView(View.AttendanceQuery);
}
setIsOpen(false);
}
};
const handleMarkAsRead = (id: string) => {
const updatedAll = (data.notifications || []).map(n =>
n.id === id ? { ...n, read: true } : n
);
updateData({ notifications: updatedAll });
dbService.saveData({ ...data, notifications: updatedAll });
};
const handleClearRead = () => {
const others = (data.notifications || []).filter(n => n.studentId !== 'admin' || (n.studentId === 'admin' && !n.read));
updateData({ notifications: others });
dbService.saveData({ ...data, notifications: others });
};
// Aceitar justificativa diretamente pela notificação
const handleAcceptJustification = (notif: Notification) => {
// Procura registros de falta pendentes de aceitação
const pendingAbsences = (data.attendance || []).filter(a =>
a.type === 'absence' && a.justification && !a.justificationAccepted
);
if (pendingAbsences.length > 0) {
// Tenta achar pelo studentId mencionado na mensagem ou aceita o mais recente
const matchedAbsence = pendingAbsences[0]; // aceita o mais recente pendente
const updatedAttendance = (data.attendance || []).map(a =>
a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : a
);
const updatedNotifs = (data.notifications || []).map(n =>
n.id === notif.id ? { ...n, read: true } : n
);
updateData({ attendance: updatedAttendance, notifications: updatedNotifs });
dbService.saveData({ ...data, attendance: updatedAttendance, notifications: updatedNotifs });
} else {
// Se não encontrou pendentes, apenas marca como lida
handleMarkAsRead(notif.id);
}
};
return (
<div className="fixed top-4 right-16 md:top-6 md:right-8 z-50">
<button
onClick={() => setIsOpen(!isOpen)}
className={`relative p-2.5 rounded-full shadow-lg border transition-all ${
unreadCount > 0
? 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100 hover:shadow-xl shadow-amber-100'
: 'bg-white text-slate-600 border-slate-100 hover:text-indigo-600 hover:shadow-xl'
}`}
title="Notificações do Sistema"
>
<Bell size={22} className={unreadCount > 0 ? "animate-bounce" : ""} />
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-black w-5 h-5 flex items-center justify-center rounded-full border-2 border-white shadow-sm animate-pulse">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
<div>
<h3 className="font-black text-slate-800 flex items-center gap-2">Avaliações Pendentes
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={handleClearRead} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-red-500 rounded-lg transition-colors" title="Limpar Lidas">
<Trash2 size={16} />
</button>
<button onClick={() => setIsOpen(false)} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors">
<X size={16} />
</button>
</div>
</div>
<div className="overflow-y-auto p-2 flex-1 relative">
{adminNotifs.length === 0 ? (
<div className="py-12 text-center text-slate-400">
<Bell size={32} className="mx-auto mb-2 opacity-20" />
<p className="text-sm font-bold">Nenhuma notificação</p>
<p className="text-xs mt-1">Sua caixa de entrada está limpa.</p>
</div>
) : (
<div className="space-y-2">
{adminNotifs.map(notif => {
const isJustificativa = notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa');
let displayMessage = notif.message;
let attachmentFromMessage = null;
if (notif.message.startsWith('{')) {
try {
const parsed = JSON.parse(notif.message);
displayMessage = parsed.motivo || displayMessage;
attachmentFromMessage = parsed.arquivo_base64 || null;
} catch(e) {}
}
const finalAttachment = notif.attachment || attachmentFromMessage;
return (
<div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : 'bg-white border-indigo-100 hover:border-indigo-300 shadow-sm'}`}>
{!notif.read && <div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500"></div>}
<div className="flex justify-between items-start mb-1 gap-4">
<h4 className={`text-base font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-emerald-500 animate-pulse'}`}>
{notif.title}
</h4>
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : 'bg-emerald-50 text-emerald-600 border border-emerald-100'}`}>
{new Date(notif.createdAt).toLocaleDateString('pt-BR')}
</span>
</div>
<p className={`text-sm font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-emerald-600/90'}`}>
{displayMessage}
</p>
{(!notif.read) && (
<div className="flex justify-end mt-2 gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{isJustificativa && (
<button
onClick={(e) => {
e.stopPropagation();
if (onNavigateToStudent) {
onNavigateToStudent(notif.studentId);
} else {
setView(View.AttendanceQuery);
}
}}
className="text-[10px] font-black uppercase text-amber-600 bg-amber-50 hover:bg-amber-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<ShieldCheck size={12} /> Ver Histórico
</button>
)}
{isJustificativa && (
<button
onClick={(e) => { e.stopPropagation(); handleAcceptJustification(notif); }}
className="text-[10px] font-black uppercase text-emerald-600 bg-emerald-50 hover:emerald-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<CheckCircle size={12} /> Aceitar
</button>
)}
{finalAttachment && (
<button
onClick={(e) => {
e.stopPropagation();
setViewingAttachment(finalAttachment);
setNotifWithAttachment(notif);
}}
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<Paperclip size={12} /> Ver Anexo
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleMarkAsRead(notif.id); }}
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<CheckCircle size={12} /> Lida
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{viewingAttachment && (
<div className="fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-4 border-b flex items-center justify-between bg-slate-50">
<h3 className="font-black text-slate-800 flex items-center gap-2">
<FileText size={20} className="text-indigo-600" /> Visualização do Documento
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleDeleteAttachment}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors"
>
<Trash2 size={14} /> Excluir Arquivo
</button>
<button
onClick={() => { setViewingAttachment(null); setNotifWithAttachment(null); }}
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto bg-slate-200 p-4 flex items-center justify-center">
{viewingAttachment.startsWith('data:application/pdf') || viewingAttachment.includes('.pdf') ? (
<iframe src={viewingAttachment} className="w-full h-full min-h-[70vh] rounded-lg shadow-sm bg-white" />
) : (
<img src={viewingAttachment} className="max-w-full max-h-full object-contain rounded-lg shadow-sm" alt="Documento" />
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminNotifications;

View File

@ -0,0 +1,395 @@
import React, { useState, useRef, useEffect } from 'react';
import { SchoolData, Attendance, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Camera, CheckCircle, XCircle, User, SwitchCamera, Loader2, Search, RefreshCw } from 'lucide-react';
import * as faceapi from '@vladmandic/face-api';
interface AttendanceCaptureProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData }) => {
const { showAlert } = useDialog();
const [cameraActive, setCameraActive] = useState(false);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [modelsLoaded, setModelsLoaded] = useState(false);
// Auto-detected state
const [detectedStudentId, setDetectedStudentId] = useState<string | null>(null);
const [detectedClassId, setDetectedClassId] = useState<string | null>(null);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setCapturedImage(null);
setShowConfirmModal(false);
setDetectedStudentId(null);
setDetectedClassId(null);
setIsProcessing(false);
setIsClosing(false);
stopCamera();
}, 400);
};
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Load Models
useEffect(() => {
const loadModels = async () => {
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
try {
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL),
]);
setModelsLoaded(true);
} catch (err) {
console.error("Error loading face-api models", err);
showAlert('Erro', "Erro ao carregar modelos de reconhecimento facial. Verifique sua conexão.", 'error');
}
};
loadModels();
}, []);
// Start Camera
const startCamera = async () => {
try {
// Stop any existing stream first
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (videoRef.current && videoRef.current.srcObject) {
const oldStream = videoRef.current.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
videoRef.current.srcObject = null;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode }
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch (e) {
console.error("Error playing video", e);
}
}
setCameraActive(true);
setIsProcessing(false);
} catch (err) {
console.error("Error accessing camera:", err);
showAlert('Erro', "Erro ao acessar a câmera. Verifique as permissões.", 'error');
}
};
// Attach stream to video when active
useEffect(() => {
if (cameraActive && videoRef.current && streamRef.current) {
videoRef.current.srcObject = streamRef.current;
videoRef.current.play().catch(e => console.error("Error playing video", e));
}
}, [cameraActive]);
// Stop Camera on unmount
useEffect(() => {
return () => {
stopCamera();
};
}, []);
const stopCamera = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setCameraActive(false);
};
const switchCamera = () => {
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
};
// Restart camera when facing mode changes
useEffect(() => {
if (cameraActive) {
startCamera();
}
}, [facingMode]);
// Face Detection Loop
useEffect(() => {
if (cameraActive && modelsLoaded && videoRef.current) {
const detectFace = async () => {
if (!videoRef.current || videoRef.current.paused || videoRef.current.ended || isProcessing || showConfirmModal) return;
try {
const detections = await faceapi.detectAllFaces(videoRef.current, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
.withFaceDescriptors();
if (detections.length > 0) {
// Find best match
const bestMatch = findBestMatch(detections[0].descriptor);
if (bestMatch) {
// Found a student!
setIsProcessing(true);
capturePhoto(bestMatch.studentId, bestMatch.classId);
}
}
} catch (e) {
console.error("Detection error", e);
}
};
intervalRef.current = setInterval(detectFace, 1000); // Check every 1s
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [cameraActive, modelsLoaded, isProcessing, showConfirmModal, data.students]);
const findBestMatch = (descriptor: Float32Array) => {
let bestDistance = 0.6; // Threshold
let bestStudentId = null;
let bestClassId = null;
// Iterate through all active students who have a face descriptor
for (const student of data.students) {
if (student.status !== 'active' || !student.faceDescriptor) continue;
const studentDescriptor = new Float32Array(student.faceDescriptor);
const distance = faceapi.euclideanDistance(descriptor, studentDescriptor);
if (distance < bestDistance) {
bestDistance = distance;
bestStudentId = student.id;
bestClassId = student.classId;
}
}
if (bestStudentId && bestClassId) {
return { studentId: bestStudentId, classId: bestClassId };
}
return null;
};
const capturePhoto = (studentId: string, classId: string) => {
if (videoRef.current && canvasRef.current) {
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (context) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = canvas.toDataURL('image/jpeg');
setCapturedImage(imageData);
setDetectedStudentId(studentId);
setDetectedClassId(classId);
setShowConfirmModal(true);
}
}
};
const confirmPresence = () => {
if (!detectedStudentId || !detectedClassId || !capturedImage) return;
// Check if already present for THIS class within a 1-hour window
// (Allows multiple presences per day if lessons are at different times)
const now = new Date();
const alreadyPresent = data.attendance.some(a => {
if (a.studentId !== detectedStudentId || a.classId !== detectedClassId) return false;
const attDate = new Date(a.date);
const isSameDay = attDate.toDateString() === now.toDateString();
const diffMs = Math.abs(now.getTime() - attDate.getTime());
return isSameDay && diffMs < (5 * 60 * 1000); // Intervalo de 5 minutos
});
if (alreadyPresent) {
showAlert('Atenção', "Aluno já marcou presença hoje!", 'warning');
cancelCapture();
return;
}
const newAttendance: Attendance = {
id: crypto.randomUUID(),
studentId: detectedStudentId,
classId: detectedClassId,
date: new Date().toISOString(),
photo: capturedImage,
type: 'presence',
verified: true
};
const updatedAttendance = [...(data.attendance || []), newAttendance];
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
// Reset for next student
setCapturedImage(null);
setShowConfirmModal(false);
setDetectedStudentId(null);
setDetectedClassId(null);
setIsProcessing(false);
closeModal();
showAlert('Sucesso', "Presença confirmada com sucesso!", 'success');
};
const cancelCapture = () => {
closeModal();
};
const detectedStudent = data.students.find(s => s.id === detectedStudentId);
const detectedClass = data.classes.find(c => c.id === detectedClassId);
return (
<div className="max-w-4xl mx-auto space-y-6 animate-in fade-in duration-300 pb-20 px-4">
<header className="text-center">
<h2 className="text-2xl md:text-3xl font-extrabold text-slate-900 tracking-tight">Registro de Presença</h2>
<p className="text-slate-500 text-sm md:text-base font-medium">Posicione o rosto para identificação automática.</p>
</header>
<div className="flex flex-col items-center gap-6">
{/* Camera View Container */}
<div className="w-full max-w-md space-y-4">
<div className="bg-black rounded-2xl overflow-hidden relative aspect-[3/4] shadow-2xl flex flex-col border-4 border-white">
{cameraActive ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover flex-1"
/>
<canvas ref={canvasRef} className="hidden" />
{/* Overlay UI */}
<div className="absolute inset-0 pointer-events-none border-[3px] border-white/20 m-6 md:m-10 rounded-2xl flex flex-col items-center justify-center">
<div className="w-40 h-40 md:w-56 md:h-56 border-2 border-dashed border-white/40 rounded-full mb-4 animate-pulse"></div>
<p className="text-white/90 text-xs md:text-sm font-bold bg-black/50 px-4 py-1.5 rounded-full backdrop-blur-md">
Aguardando rosto...
</p>
</div>
{/* Switch Camera Button (Floating) */}
<button
onClick={switchCamera}
className="absolute bottom-4 right-4 p-3 bg-white/20 hover:bg-white/30 text-white rounded-full backdrop-blur-md transition-all active:scale-90"
title="Alternar Câmera"
>
<SwitchCamera size={20} />
</button>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 p-8 text-center bg-slate-50">
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<Camera size={40} className="opacity-20" />
</div>
<p className="text-sm font-medium">A câmera está desligada.</p>
<p className="text-xs mt-1">Clique no botão abaixo para iniciar.</p>
</div>
)}
</div>
{/* Main Action Button */}
{!cameraActive ? (
<button
onClick={startCamera}
disabled={!modelsLoaded}
className="w-full py-5 bg-emerald-600 text-white rounded-2xl font-black text-xl hover:bg-emerald-700 shadow-xl shadow-emerald-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50"
>
<CheckCircle size={28} /> Marcar Presença
</button>
) : (
<button
onClick={stopCamera}
className="w-full py-5 bg-red-500 text-white rounded-2xl font-black text-xl hover:bg-red-600 shadow-xl shadow-red-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98]"
>
<XCircle size={28} /> Cancelar
</button>
)}
</div>
{/* System Status (Minimalist) */}
{!cameraActive && (
<div className="flex flex-wrap justify-center gap-4 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${modelsLoaded ? 'bg-emerald-500' : 'bg-amber-500 animate-pulse'}`}></div>
{modelsLoaded ? 'IA Pronta' : 'Carregando IA'}
</div>
<div className="flex items-center gap-1.5">
<User size={12} />
{data.students.filter(s => s.faceDescriptor).length} Faces Cadastradas
</div>
</div>
)}
</div>
{/* Confirmation Modal */}
{showConfirmModal && capturedImage && detectedStudent && (
<div className={`fixed inset-0 bg-black/95 backdrop-blur-md z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 text-center space-y-6">
<div className="space-y-1">
<h3 className="text-2xl font-black text-slate-800">Identificado!</h3>
<p className="text-slate-500 text-sm font-medium">Confirmar presença para:</p>
</div>
<div className="relative w-48 h-48 mx-auto rounded-full overflow-hidden border-4 border-emerald-500 shadow-2xl">
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
</div>
<div className="space-y-1">
<p className="text-xl font-black text-indigo-900">{detectedStudent.name}</p>
<p className="text-sm font-bold text-indigo-500 bg-indigo-50 inline-block px-3 py-1 rounded-full">{detectedClass?.name}</p>
</div>
<div className="flex flex-col gap-3 pt-4">
<button
onClick={confirmPresence}
className="w-full py-4 bg-emerald-500 text-white rounded-2xl font-black text-lg hover:bg-emerald-600 shadow-lg shadow-emerald-200 flex items-center justify-center gap-2 transition-all active:scale-95"
>
<CheckCircle size={24} /> Confirmar Agora
</button>
<button
onClick={cancelCapture}
className="w-full py-3 text-slate-400 font-bold hover:text-red-500 transition-colors"
>
Não sou eu / Cancelar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AttendanceCapture;

View File

@ -0,0 +1,826 @@
import React, { useState, useEffect } from 'react';
import { SchoolData, Attendance, Class, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Search, Calendar, User, Clock, CheckCircle, XCircle, FileDown, BookOpen, Plus, X, AlertCircle, RefreshCw, ChevronRight, Trash2, FileSignature, Paperclip } from 'lucide-react';
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import { addHeader } from '../services/pdfService';
import SearchableSelect from './SearchableSelect';
interface AttendanceQueryProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
deepLinkStudentId?: string | null;
clearDeepLink?: () => void;
}
const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, deepLinkStudentId, clearDeepLink }) => {
const { showAlert } = useDialog();
useEffect(() => {
if (deepLinkStudentId) {
const student = data.students.find(s => s.id === deepLinkStudentId);
if (student) {
const classObj = data.classes.find(c => c.id === student.classId);
if (classObj) {
setSelectedClass(classObj);
setSelectedStudent(student);
setShowStudentHistoryModal(true);
if (clearDeepLink) clearDeepLink();
}
}
}
}, [deepLinkStudentId, data.students, data.classes, clearDeepLink]);
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [showStudentListModal, setShowStudentListModal] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [showStudentHistoryModal, setShowStudentHistoryModal] = useState(false);
const [showAbsenceModal, setShowAbsenceModal] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isClosing2, setIsClosing2] = useState(false);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [absenceStudentId, setAbsenceStudentId] = useState('');
const [absenceJustification, setAbsenceJustification] = useState('');
const [absenceDate, setAbsenceDate] = useState(new Date().toISOString().split('T')[0]);
const [absenceLessonId, setAbsenceLessonId] = useState('');
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
const [attendanceForAttachment, setAttendanceForAttachment] = useState<Attendance | null>(null);
const toggleAttendanceStatus = (record: any) => {
let updatedAttendance = [...(data.attendance || [])];
if (record.isVirtual) {
// Ação do botão do Admin: criar o registro real a partir do virtual
const lesson = data.lessons.find(l => l.id === record.id.replace('v-', ''));
const newType = (record.type === 'absence' || record.type === 'awaiting') ? 'presence' : 'absence';
const newRecord: Attendance = {
id: crypto.randomUUID(),
studentId: record.studentId,
classId: record.classId,
date: lesson ? `${lesson.date}T${lesson.startTime || '00:00'}:00` : new Date().toISOString(),
verified: true,
type: newType,
...(lesson ? { lessonId: lesson.id } : {}) // Vinculação rígida para não haver duplicidade
};
// Garantir que não duplica se já houver por algum erro
const existingIdx = updatedAttendance.findIndex(a =>
a.studentId === record.studentId &&
((a as any).lessonId === lesson?.id || a.date === newRecord.date)
);
if (existingIdx >= 0) {
updatedAttendance[existingIdx] = { ...updatedAttendance[existingIdx], type: newType, justification: undefined, justificationAccepted: undefined };
} else {
updatedAttendance.push(newRecord);
}
} else {
// Toggle existing record
const newType = record.type === 'absence' ? 'presence' : 'absence';
updatedAttendance = updatedAttendance.map(a =>
a.id === record.id ? { ...a, type: newType, justification: undefined, justificationAccepted: undefined } : a
);
}
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
showAlert('Sucesso', 'Status de frequência atualizado com sucesso.', 'success');
};
const handleDeleteAttachmentRecord = () => {
if (!attendanceForAttachment || !attendanceForAttachment.justification) return;
try {
const parsed = JSON.parse(attendanceForAttachment.justification);
delete parsed.arquivo_base64;
const updatedJustification = JSON.stringify(parsed);
const updatedAttendance = (data.attendance || []).map(a =>
a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a
);
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
setViewingAttachment(null);
setAttendanceForAttachment(null);
showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success');
} catch(e) {
console.error('Erro ao excluir anexo do registro', e);
}
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setShowStudentListModal(false);
setShowAbsenceModal(false);
setIsClosing(false);
setAbsenceStudentId('');
setAbsenceJustification('');
setAbsenceLessonId('');
}, 400);
};
const closeHistoryModal = () => {
setIsClosing2(true);
setTimeout(() => {
setShowStudentHistoryModal(false);
setSelectedStudent(null);
setIsClosing2(false);
}, 400);
};
const handleAddAbsence = () => {
if (!absenceStudentId || !absenceJustification || !absenceLessonId) {
showAlert('Atenção', "⚠️ Por favor, preencha todos os campos da justificativa.", 'warning');
return;
}
const student = data.students.find(s => s.id === absenceStudentId);
if (!student) {
showAlert('Erro', "Aluno não encontrado.", 'error');
return;
}
const lesson = data.lessons.find(l => l.id === absenceLessonId);
if (!lesson) {
showAlert('Atenção', "⚠️ Por favor, selecione a aula para justificar.", 'warning');
return;
}
// Check if there is already a record for this lesson
const existingIndex = (data.attendance || []).findIndex(a =>
a.studentId === absenceStudentId && a.date.startsWith(lesson.date)
);
let updatedAttendance = [...(data.attendance || [])];
if (existingIndex >= 0) {
updatedAttendance[existingIndex] = {
...updatedAttendance[existingIndex],
type: 'absence',
justification: absenceJustification,
justificationAccepted: true,
verified: true
};
} else {
const newAbsence: Attendance = {
id: crypto.randomUUID(),
studentId: absenceStudentId,
classId: student.classId,
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
verified: true,
type: 'absence',
justification: absenceJustification,
justificationAccepted: true
};
updatedAttendance.push(newAbsence);
}
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
setAbsenceStudentId('');
setAbsenceJustification('');
setAbsenceLessonId('');
closeModal();
showAlert('Sucesso', "Falta justificada registrada com sucesso!", 'success');
};
const handleExportPDF = async (classObj: Class) => {
setIsGeneratingPDF(true);
try {
const doc = new jsPDF();
const startY = await addHeader(doc, data);
doc.setFontSize(18);
doc.text('Relatório de Frequência', 14, startY + 10);
doc.setFontSize(11);
doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18);
doc.text(`Turma: ${classObj.name}`, 14, startY + 24);
const classAttendance = (data.attendance || []).filter(record =>
record.classId === classObj.id && record.date.startsWith(selectedDate)
);
const tableData = classAttendance.map(record => {
const student = data.students.find(s => s.id === record.studentId);
const time = new Date(record.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
let justMotivo = record.justification || '-';
if (justMotivo.startsWith('{')) {
try {
const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo;
} catch(e) {}
}
return [
student?.name || 'Desconhecido',
time,
record.type === 'absence' ? (record.justificationAccepted ? 'Falta Justificada' : 'Falta') : 'Presente',
justMotivo
];
});
(doc as any).autoTable({
startY: startY + 30,
head: [['Aluno', 'Horário', 'Status', 'Justificativa']],
body: tableData,
});
doc.save(`frequencia_${classObj.name}_${selectedDate}.pdf`);
} catch (error) {
console.error('Error exporting PDF:', error);
} finally {
setIsGeneratingPDF(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Registro de Frequência</h2>
<p className="text-slate-500 font-medium">Gerencie a frequência por turma e registre faltas justificadas.</p>
</div>
<div className="flex items-center gap-3">
<input
type="date"
className="p-2 bg-white border border-slate-200 rounded-lg text-sm font-bold text-slate-700"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
/>
<button
onClick={() => setShowAbsenceModal(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-bold text-sm flex items-center gap-2 shadow-lg shadow-amber-100"
>
<Plus size={18} /> Justificar Falta
</button>
</div>
</header>
{/* Class Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.classes.map(classObj => {
const classStudents = data.students.filter(s => s.classId === classObj.id && s.status === 'active');
const attendanceCount = (data.attendance || []).filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length;
const course = data.courses.find(c => c.id === classObj.courseId);
return (
<div
key={classObj.id}
onClick={() => {
setSelectedClass(classObj);
setShowStudentListModal(true);
}}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-300 transition-all cursor-pointer group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-24 h-24 bg-indigo-50 rounded-full -mr-12 -mt-12 group-hover:scale-150 transition-transform duration-500"></div>
<div className="relative z-10">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 mb-4">
<BookOpen size={24} />
</div>
<h3 className="text-xl font-black text-slate-800 mb-1">{classObj.name}</h3>
<p className="text-sm text-slate-500 font-medium mb-4">{course?.name}</p>
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
<div className="flex items-center gap-2 text-xs font-bold text-slate-400 uppercase tracking-widest">
<User size={14} />
{classStudents.length} Alunos {attendanceCount} Registros
</div>
<div className="text-indigo-600 font-bold text-xs flex items-center gap-1 group-hover:translate-x-1 transition-transform">
Ver Alunos <ChevronRight size={14} />
</div>
</div>
</div>
</div>
);
})}
</div>
{/* === MODAL 1: Lista de Alunos da Turma === */}
{showStudentListModal && selectedClass && (
<div className={`fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div>
<h3 className="text-xl font-black text-slate-800">Alunos: {selectedClass.name}</h3>
<p className="text-sm text-slate-500 font-medium">Clique em um aluno para ver seu histórico individual.</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleExportPDF(selectedClass)}
disabled={isGeneratingPDF}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
title="Exportar PDF"
>
{isGeneratingPDF ? (
<RefreshCw size={20} className="animate-spin" />
) : (
<FileDown size={20} />
)}
</button>
<button
onClick={closeModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{(() => {
const classStudents = data.students
.filter(s => s.classId === selectedClass.id && s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name));
if (classStudents.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<User size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-bold">Nenhum aluno ativo nesta turma.</p>
</div>
);
}
return classStudents.map(student => {
const studentAttendance = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const presences = studentAttendance.filter(a => a.type === 'presence' || a.type !== 'absence').length;
const absences = studentAttendance.filter(a => a.type === 'absence').length;
const justified = studentAttendance.filter(a => a.type === 'absence' && a.justificationAccepted).length;
return (
<div
key={student.id}
onClick={() => {
setSelectedStudent(student);
setShowStudentHistoryModal(true);
}}
className="flex items-center justify-between p-4 bg-slate-50 hover:bg-indigo-50 rounded-xl border border-slate-100 hover:border-indigo-200 cursor-pointer transition-all group"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-black text-sm flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={student.photo} alt={student.name} className="w-full h-full object-cover" />
) : (
student.name.charAt(0).toUpperCase()
)}
</div>
<div>
<p className="font-bold text-slate-800 text-sm">{student.name}</p>
<p className="text-[10px] text-slate-500">Matrícula: {student.enrollmentNumber || '—'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1.5 text-[10px] font-bold">
<span className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded">{presences}P</span>
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">{absences}F</span>
{justified > 0 && <span className="px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded">{justified}J</span>}
</div>
<ChevronRight size={16} className="text-slate-300 group-hover:text-indigo-500 group-hover:translate-x-1 transition-all" />
</div>
</div>
);
});
})()}
</div>
</div>
</div>
)}
{/* === MODAL 2: Histórico Individual do Aluno === */}
{showStudentHistoryModal && selectedStudent && selectedClass && (
<div className={`fixed inset-0 bg-transparent z-[60] flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing2 ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-5xl max-h-[90vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing2 ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-black overflow-hidden flex-shrink-0">
{selectedStudent.photo ? (
<img src={selectedStudent.photo} alt={selectedStudent.name} className="w-full h-full object-cover" />
) : (
<User size={24} />
)}
</div>
<div>
<h3 className="text-xl font-black text-slate-800">{selectedStudent.name}</h3>
<p className="text-sm text-slate-500 font-medium">Histórico de Frequência {selectedClass.name}</p>
</div>
</div>
<button
onClick={closeHistoryModal}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{(() => {
const now = new Date();
const actualRecords = (data.attendance || [])
.filter(a => a.studentId === selectedStudent.id && a.classId === selectedClass.id);
const classLessons = (data.lessons || [])
.filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const virtualRecords: any[] = [];
const matchedActualRecordIds = new Set<string>();
classLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before
// Lógica de "Casamento" refinada para evitar duplicidade
const matchingRecord = actualRecords.find(a => {
// Match forte se já tiver lessonId (registros criados a partir daqui)
if ((a as any).lessonId === lesson.id) return true;
// Match de horário exato (manual antigo)
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
// Match por Janela de Tempo (Biometria): entre 30 min antes da aula até o fim da aula
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
if (!matchingRecord) {
// Não tem ponto registrado ainda. Criar o virtual apenas se já estiver na janela
if (now >= presenceStartWindow) {
const isFinished = now > lessonEnd;
virtualRecords.push({
id: `v-${lesson.id}`,
studentId: selectedStudent.id,
classId: selectedClass.id,
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
type: isFinished ? 'absence' : 'awaiting',
isVirtual: true,
lessonId: lesson.id,
awaiting: !isFinished
});
}
} else {
(matchingRecord as any).lessonId = lesson.id;
matchedActualRecordIds.add(matchingRecord.id);
}
});
// Filtrar os records reais para evitar duplicidade na lista caso haja lixo no banco
// Só mostra os reais que casaram com aulas válidas, MAIS qualquer registro avulso
// que, por algum motivo exótico não casou (fallback de segurança visual)
const uniqueActualRecords = actualRecords.filter(a => {
if (matchedActualRecordIds.has(a.id)) return true;
// Se não casou, e a aula for do mesmo dia, ignora para não poluir (provavelmente duplicata de biometria)
const hasLessonSameDay = classLessons.some(l => a.date.startsWith(l.date));
return !hasLessonSameDay;
});
const studentRecords = [...uniqueActualRecords, ...virtualRecords]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (studentRecords.length === 0) {
return (
<div className="text-center py-16 text-slate-400">
<Calendar size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-bold">Nenhum registro de frequência ou aula agendada.</p>
</div>
);
}
const presences = studentRecords.filter(a => a.type === 'presence' || (!a.type && !a.isVirtual)).length;
const absences = studentRecords.filter(a => a.type === 'absence').length;
const justified = studentRecords.filter(a => a.type === 'absence' && a.justificationAccepted).length;
return (
<>
{/* Summary bar */}
<div className="p-4 bg-slate-50 border-b border-slate-100 flex flex-wrap gap-4 text-sm font-bold">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-100 text-emerald-700 rounded-lg">
<CheckCircle size={16} /> {presences} Presenças
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-100 text-red-700 rounded-lg">
<XCircle size={14} /> {absences} Faltas
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 text-amber-700 rounded-lg">
<AlertCircle size={14} /> {justified} Justificadas
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 rounded-lg">
<BookOpen size={14} /> {studentRecords.length} Aulas
</div>
</div>
{/* Attendance table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-[10px] uppercase font-bold tracking-wider sticky top-0">
<tr>
<th className="px-6 py-4 text-sm">Data</th>
<th className="px-6 py-4 text-sm">Início (Aula)</th>
<th className="px-6 py-4 text-sm">Término (Aula)</th>
<th className="px-6 py-4 text-sm">Registro</th>
<th className="px-6 py-4 text-sm">Status</th>
<th className="px-6 py-4 text-sm">Justificativa</th>
<th className="px-6 py-4 text-sm text-center">Anexo</th>
<th className="px-6 py-4 text-sm text-right">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{studentRecords.map(record => {
const recordDate = new Date(record.date);
const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Find corresponding lesson times with high precision
const lesson = data.lessons.find(l =>
(record.isVirtual && record.id === `v-${l.id}`) ||
(record.lessonId === l.id) ||
(!record.lessonId && l.date === record.date.split('T')[0] && l.classId === record.classId)
);
let justMotivo = record.justification || '';
let justAttachment: string | null = null;
if (justMotivo.startsWith('{')) {
try {
const parsed = JSON.parse(justMotivo);
justMotivo = parsed.motivo || justMotivo;
justAttachment = parsed.arquivo_base64 || null;
} catch(e) {}
}
const isAbsence = record.type === 'absence';
const isAwaiting = record.type === 'awaiting';
const isJustified = isAbsence && record.justificationAccepted;
const hasPendingJustification = isAbsence && record.justification && !record.justificationAccepted;
const isPendente = record.awaiting;
return (
<tr key={record.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4 text-base font-bold text-slate-800">
{recordDate.toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
<div className="flex items-center gap-1.5">
<Clock size={14} className="text-slate-400" /> {lesson?.startTime || '--:--'}
</div>
</td>
<td className="px-6 py-4 text-sm font-bold text-indigo-600">
<div className="flex items-center gap-1.5">
<Clock size={14} className="text-slate-400" /> {lesson?.endTime || '--:--'}
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-500 font-medium">
<div className="flex items-center gap-1.5">
<Clock size={14} /> {record.isVirtual ? "--:--" : time}
</div>
</td>
<td className="px-6 py-4">
{isAwaiting ? (
<span className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 animate-pulse">
<Clock size={12} /> Aguardando Justificativa
</span>
) : isJustified ? (
<span className="px-3 py-1.5 bg-amber-100 text-amber-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<AlertCircle size={12} /> Falta Justificada
</span>
) : (isAbsence || isPendente) ? (
<div className="flex flex-col gap-1">
<span className="px-3 py-1.5 bg-red-100 text-red-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<XCircle size={12} /> Falta
</span>
{isPendente && (
<span className="text-[9px] font-bold text-amber-600 uppercase flex items-center gap-1 px-1">
<Clock size={10} /> Aguardando Justificativa
</span>
)}
</div>
) : (
<span className="px-3 py-1.5 bg-emerald-100 text-emerald-700 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5">
<CheckCircle size={12} /> Presente
</span>
)}
</td>
<td className="px-6 py-4">
{isAwaiting || isPendente ? (
<span className="text-xs italic text-indigo-400">Aguardando registro ou justificativa...</span>
) : justMotivo ? (
<p className="text-sm text-slate-600 truncate max-w-[200px]" title={justMotivo}>{justMotivo}</p>
) : (
<span className="text-sm text-slate-300"></span>
)}
</td>
<td className="px-6 py-4 text-center">
{justAttachment ? (
<button
onClick={() => {
setViewingAttachment(justAttachment!);
setAttendanceForAttachment(record);
}}
className="p-2 text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-xl transition-all animate-pulse shadow-md border border-indigo-200"
title="Ver Anexo"
>
<Paperclip size={18} />
</button>
) : (
<span className="text-sm text-slate-200"><Paperclip size={18} /></span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
{(isAbsence || isPendente || isAwaiting) && (
<button
onClick={() => {
setAbsenceStudentId(selectedStudent.id);
setAbsenceDate(record.date.split('T')[0]);
const lessonId = record.isVirtual ? record.id.replace('v-', '') :
data.lessons.find(l => l.date === record.date.split('T')[0] && l.classId === record.classId)?.id;
setAbsenceLessonId(lessonId || '');
setShowAbsenceModal(true);
}}
className="text-[10px] px-2 py-1.5 bg-amber-500 text-white font-bold rounded hover:bg-amber-600 transition-colors"
>
Justificar
</button>
)}
{hasPendingJustification && (
<button
onClick={() => {
const updated = (data.attendance || []).map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
updateData({ attendance: updated });
dbService.saveData({ ...data, attendance: updated });
showAlert('Sucesso', 'Justificativa aceita com sucesso.', 'success');
}}
className="text-[10px] px-2 py-1.5 bg-indigo-600 text-white font-bold rounded hover:bg-indigo-700 transition-colors"
>
Aceitar
</button>
)}
<button
onClick={() => toggleAttendanceStatus(record)}
className={`text-[10px] px-2 py-1.5 ${isAbsence || isPendente || isAwaiting ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700'} text-white font-bold rounded transition-colors whitespace-nowrap`}
>
{isAbsence || isPendente || isAwaiting ? 'Marcar Presença' : 'Marcar Falta'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
})()}
</div>
</div>
</div>
)}
{/* Justified Absence Modal */}
{showAbsenceModal && (
<div className={`fixed inset-0 bg-transparent z-[70] flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-md overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-amber-50/50">
<h3 className="text-xl font-black text-amber-800 flex items-center gap-2">
<AlertCircle size={24} /> Justificar Falta
</h3>
<button
onClick={closeModal}
className="p-2 text-amber-400 hover:text-amber-600 hover:bg-amber-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<SearchableSelect
label="Aluno"
placeholder="Selecione ou digite o nome do aluno..."
value={absenceStudentId}
onChange={(val) => setAbsenceStudentId(val)}
options={data.students
.filter(s => s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => ({ id: student.id, name: student.name }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Data</label>
<input
type="date"
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
value={absenceDate}
onChange={e => {
setAbsenceDate(e.target.value);
setAbsenceLessonId('');
}}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Aula/Horário</label>
<select
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
value={absenceLessonId}
onChange={e => setAbsenceLessonId(e.target.value)}
>
<option value="">Selecione...</option>
{data.lessons
.filter(l => l.date === absenceDate && l.status !== 'cancelled')
.filter(l => {
// If student selected, filter lessons matching student's class or any class student is in
if (!absenceStudentId) return true;
const student = data.students.find(s => s.id === absenceStudentId);
return student && l.classId === student.classId;
})
.map(lesson => {
const classObj = data.classes.find(c => c.id === lesson.classId);
const hasPresence = (data.attendance || []).some(a =>
a.studentId === absenceStudentId && a.date.startsWith(lesson.date) && a.type === 'presence'
);
return (
<option key={lesson.id} value={lesson.id} disabled={hasPresence}>
{lesson.startTime || '--:--'} - {classObj?.name} {hasPresence ? '(Presente)' : ''}
</option>
);
})
}
</select>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Justificativa</label>
<textarea
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 transition-all text-sm min-h-[100px]"
placeholder="Informe o motivo da falta..."
value={absenceJustification}
onChange={(e) => setAbsenceJustification(e.target.value)}
/>
</div>
<button
onClick={handleAddAbsence}
className="w-full py-4 bg-amber-500 text-white rounded-2xl font-black text-lg hover:bg-amber-600 shadow-lg shadow-amber-100 flex items-center justify-center gap-2 transition-all active:scale-95"
>
Salvar Justificativa
</button>
</div>
</div>
</div>
)}
{viewingAttachment && (
<div className="fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-4 border-b flex items-center justify-between bg-slate-50">
<h3 className="font-black text-slate-800 flex items-center gap-2">
<FileSignature size={20} className="text-indigo-600" /> Visualização do Documento
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleDeleteAttachmentRecord}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors"
>
<Trash2 size={14} /> Excluir Arquivo
</button>
<button
onClick={() => { setViewingAttachment(null); setAttendanceForAttachment(null); }}
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto bg-slate-200 p-4 flex items-center justify-center">
{viewingAttachment.startsWith('data:application/pdf') || viewingAttachment.includes('.pdf') ? (
<iframe src={viewingAttachment} className="w-full h-full min-h-[70vh] rounded-lg shadow-sm bg-white" />
) : (
<img src={viewingAttachment} className="max-w-full max-h-full object-contain rounded-lg shadow-sm" alt="Documento" />
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AttendanceQuery;

234
manager/components/Auth.tsx Normal file
View File

@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { SchoolData, User } from '../types';
import { BookOpen, User as UserIcon, Lock, ArrowRight, Loader2, Shield } from 'lucide-react';
interface AuthProps {
data: SchoolData;
onLogin: (user: User) => void;
onUpdateUsers: (newUsers: User[]) => void;
}
const Auth: React.FC<AuthProps> = ({ data, onLogin, onUpdateUsers }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isRecovering, setIsRecovering] = useState(false);
const [formData, setFormData] = useState({
name: '',
displayName: '',
password: '',
newPassword: '',
cpf: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Simulate network delay for better UX
await new Promise(resolve => setTimeout(resolve, 800));
// Normalize inputs
const loginName = (formData.name || '').trim().toLowerCase();
const loginPass = formData.password.trim();
const loginCpf = formData.cpf.replace(/\D/g, '');
// Ensure data.users exists before finding
const usersList = data.users || [];
if (isRecovering) {
const userIndex = usersList.findIndex(u => (u.name || '').toLowerCase() === loginName);
const user = usersList[userIndex];
if (user) {
const storedCpf = (user.cpf || '').replace(/\D/g, '');
if (!loginCpf) {
setError('O CPF é obrigatório para recuperação.');
} else if (storedCpf && storedCpf !== loginCpf) {
setError('CPF incorreto para este usuário.');
} else if (!storedCpf) {
setError('Este usuário não possui CPF cadastrado. Entre em contato com o suporte.');
} else if (!formData.newPassword) {
setError('Digite a nova senha.');
} else if (!formData.displayName) {
setError('O Nome Completo é obrigatório.');
} else {
const updatedUsers = [...usersList];
updatedUsers[userIndex] = {
...updatedUsers[userIndex],
password: formData.newPassword.trim(),
displayName: formData.displayName.trim()
};
onUpdateUsers(updatedUsers);
setSuccess('Dados atualizados com sucesso! Faça login.');
setIsRecovering(false);
setFormData({ name: '', displayName: '', password: '', newPassword: '', cpf: '' });
}
} else {
setError('Usuário não encontrado.');
}
} else {
// Login Logic
const user = usersList.find(u =>
(u.name || '').toLowerCase() === loginName &&
u.password === loginPass
);
if (user) {
onLogin(user);
} else {
setError('Usuário ou senha inválidos.');
}
}
setIsLoading(false);
};
const inputClass = "w-full pl-10 pr-4 py-3 bg-slate-50 text-slate-900 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all font-medium text-sm";
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden border border-slate-100 animate-zoom-in">
<div className="p-8 pb-6 bg-white">
<div className="flex justify-center mb-6">
{data.logo ? (
<img src={data.logo} alt="Logo" className="h-28 w-auto max-w-full object-contain" />
) : (
<div className="w-16 h-16 bg-indigo-50 rounded-2xl flex items-center justify-center text-indigo-600 shadow-sm">
<BookOpen size={32} />
</div>
)}
</div>
<h2 className="text-2xl font-black text-center text-slate-800 mb-1">
{isRecovering ? 'Recuperar Senha' : 'Acesso Restrito'}
</h2>
<p className="text-center text-slate-500 text-sm font-medium mb-8">
{isRecovering ? 'Crie uma nova senha para o seu usuário.' : 'Insira suas credenciais para acessar o EduManager.'}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative group">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Nome de Usuário"
required
className={inputClass}
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div>
{isRecovering && (
<>
<div className="relative group">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Nome Completo (Ex: João Silva)"
required
className={inputClass}
value={formData.displayName}
onChange={e => setFormData({...formData, displayName: e.target.value})}
/>
</div>
<div className="relative group">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Confirme seu CPF"
required
className={inputClass}
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({...formData, cpf: val});
}}
/>
</div>
</>
)}
{!isRecovering ? (
<div className="relative group">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="password"
placeholder="Senha"
required
className={inputClass}
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
/>
</div>
) : (
<div className="relative group">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="password"
placeholder="Nova Senha"
required
className={inputClass}
value={formData.newPassword}
onChange={e => setFormData({...formData, newPassword: e.target.value})}
/>
</div>
)}
{error && (
<div className="p-3 bg-red-50 text-red-600 text-xs font-bold rounded-lg text-center animate-in fade-in">
{error}
</div>
)}
{success && (
<div className="p-3 bg-emerald-50 text-emerald-600 text-xs font-bold rounded-lg text-center animate-in fade-in">
{success}
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm shadow-lg shadow-indigo-200 hover:bg-indigo-700 hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed mt-4"
>
{isLoading ? (
<Loader2 className="animate-spin" size={20} />
) : (
<>
{isRecovering ? 'Redefinir Senha' : 'Entrar'}
{!isRecovering && <ArrowRight size={18} />}
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setIsRecovering(!isRecovering);
setError('');
setSuccess('');
setFormData({ name: '', displayName: '', password: '', newPassword: '', cpf: '' });
}}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
>
{isRecovering ? 'Voltar para o Login' : 'Esqueceu a senha?'}
</button>
</div>
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 text-center">
<p className="text-[10px] text-slate-400 uppercase font-bold">Acesso Padrão: admin / admin</p>
</div>
</div>
</div>
);
};
export default Auth;

View File

@ -0,0 +1,674 @@
import React, { useState, useRef, useEffect } from 'react';
import { SchoolData, Certificate, Student, CertificateTemplate, TextOverlay } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Award, Upload, Search, Trash2, Download, Eye, X, Image as ImageIcon, Edit2, Save, Type, Move, Palette, Baseline, Layout, Copy, Check, Plus } from 'lucide-react';
import jsPDF from 'jspdf';
interface CertificatesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Certificates: React.FC<CertificatesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [searchTerm, setSearchTerm] = useState('');
const [selectedStudentId, setSelectedStudentId] = useState('');
const [description, setDescription] = useState('');
const [frontImage, setFrontImage] = useState<string | null>(null);
const [backImage, setBackImage] = useState<string | null>(null);
const [previewCertificate, setPreviewCertificate] = useState<Certificate | null>(null);
const [editingCertificate, setEditingCertificate] = useState<Certificate | null>(null);
const [activeTab, setActiveTab] = useState<'front' | 'back'>('front');
const [templateName, setTemplateName] = useState('');
const [showTemplateModal, setShowTemplateModal] = useState(false);
// Overlays States
const [frontOverlays, setFrontOverlays] = useState<TextOverlay[]>([]);
const [backOverlays, setBackOverlays] = useState<TextOverlay[]>([]);
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
const fileInputFrontRef = useRef<HTMLInputElement>(null);
const fileInputBackRef = useRef<HTMLInputElement>(null);
const activeOverlays = activeTab === 'front' ? frontOverlays : backOverlays;
const setActiveOverlays = activeTab === 'front' ? setFrontOverlays : setBackOverlays;
const selectedOverlay = activeOverlays.find(o => o.id === selectedOverlayId);
const handleAddOverlay = () => {
const newOverlay: TextOverlay = {
id: crypto.randomUUID(),
text: activeTab === 'front' ? 'Certificamos que {{aluno}}...' : 'Conteúdo do verso...',
x: 50,
y: 50,
fontSize: activeTab === 'front' ? 24 : 12,
color: '#000000'
};
setActiveOverlays([...activeOverlays, newOverlay]);
setSelectedOverlayId(newOverlay.id);
};
const handleUpdateOverlay = (id: string, updates: Partial<TextOverlay>) => {
setActiveOverlays(activeOverlays.map(o => o.id === id ? { ...o, ...updates } : o));
};
const handleRemoveOverlay = (id: string) => {
setActiveOverlays(activeOverlays.filter(o => o.id !== id));
if (selectedOverlayId === id) setSelectedOverlayId(null);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>, side: 'front' | 'back') => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
if (side === 'front') setFrontImage(reader.result as string);
else setBackImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSaveCertificate = () => {
if (!selectedStudentId || !frontImage) {
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e faça upload da imagem da frente.', 'warning');
return;
}
const certificateData: Certificate = {
id: editingCertificate ? editingCertificate.id : crypto.randomUUID(),
studentId: selectedStudentId,
description,
frontImage,
backImage: backImage || undefined,
issueDate: editingCertificate ? editingCertificate.issueDate : new Date().toISOString(),
frontOverlays,
backOverlays
};
let updatedCertificates;
if (editingCertificate) {
updatedCertificates = (data.certificates || []).map(c => c.id === editingCertificate.id ? certificateData : c);
} else {
updatedCertificates = [...(data.certificates || []), certificateData];
}
updateData({ certificates: updatedCertificates });
dbService.saveData({ ...data, certificates: updatedCertificates });
resetForm();
showAlert('Sucesso', editingCertificate ? '✅ Certificado atualizado!' : '✅ Certificado salvo com sucesso!', 'success');
};
const handleSaveTemplate = () => {
if (!templateName || !frontImage) {
showAlert('Atenção', '⚠️ Informe um nome para o modelo e carregue pelo menos a imagem da frente.', 'warning');
return;
}
const newTemplate: CertificateTemplate = {
id: crypto.randomUUID(),
name: templateName,
frontImage,
backImage: backImage || undefined,
frontOverlays,
backOverlays
};
const updatedTemplates = [...(data.certificateTemplates || []), newTemplate];
updateData({ certificateTemplates: updatedTemplates });
dbService.saveData({ ...data, certificateTemplates: updatedTemplates });
setTemplateName('');
setShowTemplateModal(false);
showAlert('Sucesso', '✅ Modelo salvo com sucesso!', 'success');
};
const loadTemplate = (template: CertificateTemplate) => {
setFrontImage(template.frontImage);
setBackImage(template.backImage);
setFrontOverlays(template.frontOverlays || []);
setBackOverlays(template.backOverlays || []);
setSelectedOverlayId(null);
showAlert('Modelo Carregado', `✅ Modelo "${template.name}" carregado!`, 'success');
};
const resetForm = () => {
setSelectedStudentId('');
setDescription('');
setFrontImage(null);
setBackImage(null);
setFrontOverlays([]);
setBackOverlays([]);
setSelectedOverlayId(null);
setEditingCertificate(null);
if (fileInputFrontRef.current) fileInputFrontRef.current.value = '';
if (fileInputBackRef.current) fileInputBackRef.current.value = '';
};
const handleEditCertificate = (cert: Certificate) => {
setEditingCertificate(cert);
setSelectedStudentId(cert.studentId);
setDescription(cert.description || '');
setFrontImage(cert.frontImage);
setBackImage(cert.backImage);
setFrontOverlays(cert.frontOverlays || []);
setBackOverlays(cert.backOverlays || []);
setSelectedOverlayId(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleDeleteCertificate = (id: string) => {
showConfirm(
'Excluir Certificado',
'⚠️ Tem certeza que deseja excluir este certificado?',
() => {
const updatedCertificates = (data.certificates || []).filter(c => c.id !== id);
updateData({ certificates: updatedCertificates });
dbService.saveData({ ...data, certificates: updatedCertificates });
}
);
};
const handleDeleteTemplate = (id: string) => {
showConfirm(
'Excluir Modelo',
'⚠️ Excluir este modelo?',
() => {
const updatedTemplates = (data.certificateTemplates || []).filter(t => t.id !== id);
updateData({ certificateTemplates: updatedTemplates });
dbService.saveData({ ...data, certificateTemplates: updatedTemplates });
}
);
};
const getStudentStats = (studentId: string) => {
const studentGrades = (data.grades || []).filter(g => g.studentId === studentId);
const media = studentGrades.length > 0
? (studentGrades.reduce((acc, curr) => acc + curr.value, 0) / studentGrades.length).toFixed(1)
: '0.0';
const studentAttendance = (data.attendance || []).filter(a => a.studentId === studentId);
const presences = studentAttendance.filter(a => a.type === 'presence').length;
const frequencia = studentAttendance.length > 0
? ((presences / studentAttendance.length) * 100).toFixed(0)
: '0';
return { media, frequencia };
};
const handleDownloadPDF = (cert: Certificate) => {
const doc = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4'
});
const width = doc.internal.pageSize.getWidth();
const height = doc.internal.pageSize.getHeight();
const student = data.students.find(s => s.id === cert.studentId);
const studentName = student?.name || 'Aluno';
const { media, frequencia } = getStudentStats(cert.studentId);
const historico = (data.grades || [])
.filter(g => g.studentId === cert.studentId)
.map(g => {
const subject = data.subjects.find(s => s.id === g.subjectId);
return `${subject?.name || 'Disciplina'}: ${g.value}`;
})
.join('\n');
// Helper to draw wrapped text
const drawWrappedText = (text: string, xPerc: number, yPerc: number, fSize: number, color: string) => {
const processedText = text
.replace(/{{aluno}}/gi, studentName)
.replace(/{{media}}/gi, media)
.replace(/{{frequencia}}/gi, `${frequencia}%`)
.replace(/{{historico}}/gi, historico);
doc.setTextColor(color);
doc.setFontSize(fSize);
const xPos = (xPerc / 100) * width;
const yPos = (yPerc / 100) * height;
const maxW = width * 0.8; // 80% of page width
const lines = doc.splitTextToSize(processedText, maxW);
doc.text(lines, xPos, yPos, { align: 'center' });
};
// Front
doc.addImage(cert.frontImage, 'JPEG', 0, 0, width, height);
(cert.frontOverlays || []).forEach(o => {
drawWrappedText(o.text, o.x, o.y, o.fontSize, o.color);
});
// Back (Only if has back image or back overlays)
if (cert.backImage || (cert.backOverlays && cert.backOverlays.length > 0)) {
doc.addPage();
if (cert.backImage) {
doc.addImage(cert.backImage, 'JPEG', 0, 0, width, height);
}
(cert.backOverlays || []).forEach(o => {
drawWrappedText(o.text, o.x, o.y, o.fontSize, o.color);
});
}
doc.save(`Certificado_${studentName.replace(/\s+/g, '_')}.pdf`);
};
const filteredCertificates = (data.certificates || []).filter(cert => {
const student = data.students.find(s => s.id === cert.studentId);
return (student?.name || '').toLowerCase().includes((searchTerm || '').toLowerCase());
});
const currentStudentName = data.students.find(s => s.id === selectedStudentId)?.name || 'Nome do Aluno';
const { media: currentMedia, frequencia: currentFrequencia } = getStudentStats(selectedStudentId);
const currentHistorico = (data.grades || [])
.filter(g => g.studentId === selectedStudentId)
.map(g => {
const subject = data.subjects.find(s => s.id === g.subjectId);
return `${subject?.name || 'Disciplina'}: ${g.value}`;
})
.join('\n') || 'Matemática: 9.5\nPortuguês: 8.0\nHistória: 10.0';
const processPreviewText = (text: string) => {
return text
.replace(/{{aluno}}/gi, currentStudentName)
.replace(/{{media}}/gi, currentMedia)
.replace(/{{frequencia}}/gi, `${currentFrequencia}%`)
.replace(/{{historico}}/gi, currentHistorico);
};
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Certificados</h2>
<p className="text-slate-500 font-medium">Gerencie, edite e emita certificados personalizados.</p>
</div>
<div className="flex gap-2">
{editingCertificate && (
<button
onClick={resetForm}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors font-bold text-sm flex items-center gap-2"
>
<X size={18} /> Cancelar Edição
</button>
)}
<button
onClick={() => setShowTemplateModal(true)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
>
<Layout size={18} /> Modelos Salvos
</button>
</div>
</header>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
{/* Form & Preview Section */}
<div className="xl:col-span-8 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-6">
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Award size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">
{editingCertificate ? 'Editar Certificado' : 'Novo Certificado'}
</h3>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setActiveTab('front')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'front' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
FRENTE
</button>
<button
onClick={() => setActiveTab('back')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'back' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
VERSO
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Aluno</label>
<select
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-medium"
value={selectedStudentId}
onChange={(e) => setSelectedStudentId(e.target.value)}
>
<option value="">Selecione um aluno...</option>
{data.students
.filter(s => s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<option key={student.id} value={student.id}>{student.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Descrição Interna</label>
<input
type="text"
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="Ex: Conclusão de Curso 2024"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-1">Textos ({activeTab === 'front' ? 'Frente' : 'Verso'})</h4>
<button
onClick={handleAddOverlay}
className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-[10px] flex items-center gap-1.5"
>
<Plus size={14} /> Adicionar Texto
</button>
</div>
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{activeOverlays.map((overlay, index) => (
<div
key={overlay.id}
className={`p-4 rounded-2xl border transition-all space-y-4 ${selectedOverlayId === overlay.id ? 'border-indigo-500 bg-indigo-50/30 ring-2 ring-indigo-100' : 'border-slate-100 bg-slate-50'}`}
onClick={() => setSelectedOverlayId(overlay.id)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Texto #{index + 1}</span>
<button
onClick={(e) => { e.stopPropagation(); handleRemoveOverlay(overlay.id); }}
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between items-end mb-1.5 ml-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest">Conteúdo</label>
<span className="text-[9px] text-indigo-500 font-bold">Variáveis: {"{{aluno}}"}, {"{{media}}"}, {"{{frequencia}}"}, {"{{historico}}"}</span>
</div>
<textarea
className="w-full px-4 py-3 bg-white text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm min-h-[80px]"
value={overlay.text}
onChange={(e) => handleUpdateOverlay(overlay.id, { text: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Baseline size={12} /> Fonte
</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm" value={overlay.fontSize} onChange={(e) => handleUpdateOverlay(overlay.id, { fontSize: parseInt(e.target.value) || 0 })} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Palette size={12} /> Cor
</label>
<input type="color" className="h-11 w-full p-1 bg-white border border-slate-200 rounded-xl cursor-pointer" value={overlay.color} onChange={(e) => handleUpdateOverlay(overlay.id, { color: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Move size={12} /> Posição (X: {overlay.x}% | Y: {overlay.y}%)
</label>
<div className="space-y-4 px-2">
<input type="range" min="0" max="100" value={overlay.x} onChange={(e) => handleUpdateOverlay(overlay.id, { x: parseInt(e.target.value) || 0 })} className="w-full accent-indigo-600" />
<input type="range" min="0" max="100" value={overlay.y} onChange={(e) => handleUpdateOverlay(overlay.id, { y: parseInt(e.target.value) || 0 })} className="w-full accent-indigo-600" />
</div>
</div>
</div>
</div>
))}
{activeOverlays.length === 0 && (
<div className="py-8 text-center text-slate-400 italic text-sm border-2 border-dashed border-slate-100 rounded-2xl">
Nenhum texto adicionado. Clique em "Adicionar Texto".
</div>
)}
</div>
</div>
</div>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4">
{/* Front Image Upload */}
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Frente do Certificado</label>
<div
className={`aspect-[1.414/1] rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center relative overflow-hidden group bg-slate-50 ${activeTab === 'front' ? 'border-indigo-500 ring-4 ring-indigo-50' : 'border-slate-300'}`}
onClick={() => fileInputFrontRef.current?.click()}
>
{frontImage ? (
<>
<img src={frontImage} alt="Frente" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white font-bold text-xs">Alterar Frente</div>
</>
) : (
<>
<ImageIcon className="text-slate-400 mb-2" size={32} />
<span className="text-xs font-bold text-slate-500 uppercase">Upload Frente</span>
</>
)}
<input type="file" ref={fileInputFrontRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, 'front')} />
</div>
</div>
{/* Back Image Upload */}
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Verso do Certificado</label>
<div
className={`aspect-[1.414/1] rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center relative overflow-hidden group bg-slate-50 ${activeTab === 'back' ? 'border-indigo-500 ring-4 ring-indigo-50' : 'border-slate-300'}`}
onClick={() => fileInputBackRef.current?.click()}
>
{backImage ? (
<>
<img src={backImage} alt="Verso" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white font-bold text-xs">Alterar Verso</div>
</>
) : (
<>
<ImageIcon className="text-slate-400 mb-2" size={32} />
<span className="text-xs font-bold text-slate-500 uppercase">Upload Verso</span>
</>
)}
<input type="file" ref={fileInputBackRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, 'back')} />
</div>
</div>
</div>
</div>
</div>
<div className="pt-6 border-t border-slate-100 flex gap-4">
<button
onClick={() => setShowTemplateModal(true)}
className="flex-1 py-4 bg-white border-2 border-indigo-100 text-indigo-600 rounded-2xl hover:bg-indigo-50 transition-all font-black text-lg flex items-center justify-center gap-2"
>
<Copy size={24} /> Salvar como Modelo
</button>
<button
onClick={handleSaveCertificate}
className="flex-[2] py-4 bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 font-black text-lg flex items-center justify-center gap-2 active:scale-95"
>
{editingCertificate ? <Save size={24} /> : <Upload size={24} />}
{editingCertificate ? 'Atualizar Certificado' : 'Salvar Certificado'}
</button>
</div>
</div>
{/* Visual Preview Montage */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-4">
<h3 className="text-lg font-black text-slate-800 flex items-center gap-2">
<Eye size={20} className="text-indigo-600" /> Pré-visualização ({activeTab === 'front' ? 'FRENTE' : 'VERSO'})
</h3>
<div className="relative aspect-[1.414/1] w-full bg-slate-100 rounded-xl overflow-hidden shadow-inner border border-slate-200">
{activeTab === 'front' ? (
<>
{frontImage ? <img src={frontImage} alt="Preview Front" className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-slate-400 font-bold">Carregue a imagem da frente</div>}
{frontOverlays.map(o => (
<div
key={o.id}
className={`absolute pointer-events-none text-center transform -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap ${selectedOverlayId === o.id ? 'ring-2 ring-indigo-500 ring-offset-2 rounded px-1' : ''}`}
style={{
left: `${o.x}%`,
top: `${o.y}%`,
fontSize: `${o.fontSize}px`,
color: o.color,
width: '80%',
fontWeight: 'bold',
textShadow: '0 1px 2px rgba(0,0,0,0.1)'
}}
>
{processPreviewText(o.text)}
</div>
))}
</>
) : (
<>
{backImage ? <img src={backImage} alt="Preview Back" className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-slate-400 font-bold">Carregue a imagem do verso</div>}
{backOverlays.map(o => (
<div
key={o.id}
className={`absolute pointer-events-none text-center transform -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap ${selectedOverlayId === o.id ? 'ring-2 ring-indigo-500 ring-offset-2 rounded px-1' : ''}`}
style={{
left: `${o.x}%`,
top: `${o.y}%`,
fontSize: `${o.fontSize}px`,
color: o.color,
width: '80%',
fontWeight: 'bold',
textShadow: '0 1px 2px rgba(0,0,0,0.1)'
}}
>
{processPreviewText(o.text)}
</div>
))}
</>
)}
<div className="absolute bottom-4 right-4 bg-black/50 backdrop-blur-md text-white text-[10px] font-bold px-3 py-1.5 rounded-full uppercase">
{activeTab} (MODO EDIÇÃO)
</div>
</div>
</div>
</div>
{/* List Section */}
<div className="xl:col-span-4 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl h-full flex flex-col">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar certificados..."
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-2 custom-scrollbar">
{filteredCertificates.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Award size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-medium">Nenhum certificado.</p>
</div>
) : (
filteredCertificates.map(cert => {
const student = data.students.find(s => s.id === cert.studentId);
return (
<div key={cert.id} className="p-4 bg-slate-50 rounded-2xl border border-slate-100 hover:border-indigo-200 transition-all group relative">
<div className="flex items-center gap-4 mb-3">
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold shrink-0"><Award size={20} /></div>
<div className="min-w-0">
<h4 className="font-bold text-slate-800 truncate">{student?.name || 'Aluno Removido'}</h4>
<p className="text-[10px] text-slate-500 font-medium">Emitido: {new Date(cert.issueDate).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => handleDownloadPDF(cert)} className="flex-1 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors text-[10px] font-bold flex items-center justify-center gap-1"><Download size={14} /> PDF</button>
<button onClick={() => handleEditCertificate(cert)} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" title="Editar"><Edit2 size={16} /></button>
<button onClick={() => handleDeleteCertificate(cert.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Excluir"><Trash2 size={16} /></button>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</div>
{/* TEMPLATE MODAL */}
{showTemplateModal && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 overflow-y-auto animate-in fade-in duration-300">
<div className="bg-white rounded-3xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh] my-auto animate-slide-up">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800">Modelos de Certificado</h3>
<p className="text-xs text-slate-500">Salve ou carregue configurações pré-definidas.</p>
</div>
<button onClick={() => setShowTemplateModal(false)} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"><X size={20} /></button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200 space-y-4">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">Salvar Configuração Atual</h4>
<div className="flex gap-2">
<input
type="text"
placeholder="Nome do modelo (ex: Curso Informática)"
className="flex-1 px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-2 focus:ring-indigo-500 outline-none"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
/>
<button onClick={handleSaveTemplate} className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2">
<Save size={18} /> Salvar
</button>
</div>
</div>
<div className="space-y-3">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-1">Modelos Disponíveis</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(data.certificateTemplates || []).map(template => (
<div key={template.id} className="p-4 bg-white border border-slate-200 rounded-2xl hover:border-indigo-300 transition-all group flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-800 truncate">{template.name}</span>
<button onClick={() => handleDeleteTemplate(template.id)} className="text-slate-300 hover:text-red-500 transition-colors"><Trash2 size={16} /></button>
</div>
<button
onClick={() => { loadTemplate(template); setShowTemplateModal(false); }}
className="w-full py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-all font-bold text-xs flex items-center justify-center gap-2"
>
<Check size={14} /> Carregar Modelo
</button>
</div>
))}
{(data.certificateTemplates || []).length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhum modelo salvo ainda.</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Certificates;

View File

@ -0,0 +1,584 @@
import React, { useState } from 'react';
import { SchoolData, Class } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Clock, User, Book, Printer, RefreshCw, Calendar, Settings } from 'lucide-react';
import { pdfService } from '../services/pdfService';
import LessonSchedule from './LessonSchedule';
import { dbService } from '../services/dbService';
interface ClassesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
onNavigateToClass: (classId: string, studentId?: string) => void;
}
const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingClass, setEditingClass] = useState<Class | null>(null);
const [isGeneratingPDF, setIsGeneratingPDF] = useState<string | null>(null);
const [scheduleClass, setScheduleClass] = useState<Class | null>(null); // For LessonSchedule component
const [viewingStudentsClass, setViewingStudentsClass] = useState<Class | null>(null); // For student list modal
const [formData, setFormData] = useState<Omit<Class, 'id'>>({
name: '',
courseId: '',
teacher: '',
schedule: '',
scheduleDay: '',
maxStudents: 15,
startDate: '',
endDate: '',
defaultStartTime: '',
defaultEndTime: ''
});
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
const [quickTimeClass, setQuickTimeClass] = useState<Class | null>(null);
const [quickStartTime, setQuickStartTime] = useState('');
const [quickEndTime, setQuickEndTime] = useState('');
// Auto-calculate end date based on course durationMonths
React.useEffect(() => {
if (formData.courseId && formData.startDate) {
const course = data.courses.find(c => c.id === formData.courseId);
if (course && course.durationMonths) {
const start = new Date(formData.startDate + 'T12:00:00Z');
const end = new Date(start);
end.setUTCMonth(end.getUTCMonth() + course.durationMonths);
const endString = end.toISOString().split('T')[0];
setFormData(prev => ({ ...prev, endDate: endString }));
}
}
}, [formData.courseId, formData.startDate, data.courses]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.courseId || !formData.teacher) {
showAlert('Atenção', '⚠️ Por favor, preencha todos os campos obrigatórios.', 'warning');
return;
}
const todayStr = new Date().toISOString().split('T')[0];
// Removido bloqueio de data retroativa para permitir planejamento histórico
const newClassId = editingClass ? editingClass.id : crypto.randomUUID();
const resolvedScheduleName = formData.scheduleDay ? DAY_NAMES[parseInt(formData.scheduleDay)] : formData.schedule;
const newClass: Class = {
...formData,
id: newClassId,
schedule: resolvedScheduleName
};
let updatedLessons = [...(data.lessons || [])];
// Gerar cronograma automaticamente
if (newClass.startDate && newClass.endDate && newClass.scheduleDay && newClass.defaultStartTime && newClass.defaultEndTime) {
let generationStartStr = newClass.startDate;
if (editingClass) {
// Ao editar, removemos apenas as aulas que coincidem ou são futuras em relação ao ponto de alteração
// Mas o sistema agora permite gerar todo o período do curso (mesmo retroativo) se solicitado.
updatedLessons = updatedLessons.filter(l => !(l.classId === newClass.id && l.date >= generationStartStr));
}
const generatedLessons = [];
let currentDate = new Date(generationStartStr + 'T12:00:00Z');
const endObject = new Date(newClass.endDate + 'T12:00:00Z');
const targetDay = parseInt(newClass.scheduleDay);
// Avançar até o primeiro dia da semana alvo a partir da data de início (nunca para trás)
while (currentDate.getUTCDay() !== targetDay) {
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
}
while (currentDate <= endObject) {
const dateString = currentDate.toISOString().split('T')[0];
generatedLessons.push({
id: crypto.randomUUID(),
classId: newClass.id,
date: dateString,
startTime: newClass.defaultStartTime,
endTime: newClass.defaultEndTime,
status: 'scheduled',
type: 'regular'
});
currentDate.setUTCDate(currentDate.getUTCDate() + 7);
}
updatedLessons = [...updatedLessons, ...generatedLessons];
}
let updatedClasses = [];
if (editingClass) {
updatedClasses = data.classes.map(c => c.id === editingClass.id ? newClass : c);
} else {
updatedClasses = [...data.classes, newClass];
}
updateData({ classes: updatedClasses, lessons: updatedLessons });
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
closeModal();
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingClass(null);
setFormData({ name: '', courseId: '', teacher: '', schedule: '', maxStudents: 15 });
}, 400);
};
const handleEdit = (cls: Class) => {
setEditingClass(cls);
setFormData({ ...cls });
setIsModalOpen(true);
};
const handleDelete = (id: string) => {
showConfirm(
'Excluir Turma',
'⚠️ Tem certeza que deseja excluir esta turma? Isso não removerá os alunos, mas eles ficarão sem turma.',
() => {
updateData({ classes: data.classes.filter(c => c.id !== id) });
}
);
};
const handleDownloadClassList = async (cls: Class) => {
setIsGeneratingPDF(cls.id);
try {
await pdfService.generateClassListPDF(cls, data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(null);
}
};
const handleQuickTimeSave = () => {
if (!quickTimeClass || !quickStartTime || !quickEndTime) {
showAlert('Atenção', 'Preencha início e término.', 'warning');
return;
}
if (quickStartTime >= quickEndTime) {
showAlert('Atenção', 'Fim deve ser maior que início.', 'warning');
return;
}
// Save class default times
const updatedClass = { ...quickTimeClass, defaultStartTime: quickStartTime, defaultEndTime: quickEndTime };
const updatedClasses = data.classes.map(c => c.id === quickTimeClass.id ? updatedClass : c);
// Update all future scheduled lessons for this class
const today = new Date().toISOString().split('T')[0];
const updatedLessons = (data.lessons || []).map(l => {
if (l.classId === quickTimeClass.id && l.status === 'scheduled' && l.date >= today) {
return { ...l, startTime: quickStartTime, endTime: quickEndTime };
}
return l;
});
updateData({ classes: updatedClasses, lessons: updatedLessons });
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
setQuickTimeClass(null);
showAlert('Sucesso', 'Horário alterado para a turma e todas as aulas futuras atualizadas!', 'success');
};
const calculateAge = (birthDate: string) => {
if (!birthDate) return null;
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Turmas</h2>
<p className="text-slate-500">Controle de horários e ocupação das salas.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Nova Turma
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.classes.map(cls => {
const studentCount = data.students.filter(s => s.classId === cls.id).length;
const occupancyPercent = Math.min(100, (studentCount / cls.maxStudents) * 100);
const course = data.courses.find(c => c.id === cls.courseId);
const now = new Date();
const clsLessons = (data.lessons || []).filter(l => l.classId === cls.id && l.status !== 'cancelled');
const isOngoing = clsLessons.some(l => {
if (!l.startTime || !l.endTime) return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (lDate.getDate() !== now.getDate() || lDate.getMonth() !== now.getMonth() || lDate.getFullYear() !== now.getFullYear()) return false;
const [sh, sm] = l.startTime.split(':').map(Number);
const lStart = new Date(now); lStart.setHours(sh, sm, 0, 0);
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(now); lEnd.setHours(eh, em, 0, 0);
return now >= lStart && now <= lEnd;
});
return (
<div key={cls.id} className={`bg-white p-7 rounded-xl border shadow-sm hover:shadow-xl transition-all group flex flex-col h-full ${isOngoing ? 'border-blue-400 border-b-4 border-b-blue-500 shadow-blue-100' : 'border-slate-200 border-b-4 border-b-indigo-500/20 hover:border-b-indigo-500'}`}>
<div className="flex justify-between items-start mb-5 relative">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-black text-slate-900 leading-tight">{cls.name}</h3>
{isOngoing && (
<span className="px-2 py-0.5 bg-blue-600 text-white text-[9px] font-black uppercase rounded-full animate-pulse shadow-sm flex items-center gap-1">
<Clock size={10} /> Em andamento
</span>
)}
</div>
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-[0.2em]">{course?.name || 'Sem Curso Vinculado'}</span>
{cls.defaultStartTime && cls.defaultEndTime && (
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-lg border border-indigo-100">
<Clock size={12} /> {cls.defaultStartTime} - {cls.defaultEndTime}
</div>
)}
</div>
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); handleDownloadClassList(cls); }}
disabled={isGeneratingPDF === cls.id}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg transition-all disabled:opacity-50 bg-slate-50 hover:bg-indigo-50"
title="Imprimir Diário"
>
{isGeneratingPDF === cls.id ? <RefreshCw size={16} className="animate-spin" /> : <Printer size={16} />}
</button>
<button onClick={(e) => { e.stopPropagation(); handleEdit(cls); }} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-50 rounded-lg transition-all" title="Editar Turma">
<Edit2 size={16} />
</button>
<button onClick={(e) => { e.stopPropagation(); handleDelete(cls.id); }} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all" title="Excluir Turma">
<Trash2 size={16} />
</button>
</div>
</div>
<div className="space-y-3 mb-5 flex-1">
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<User size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Professor</p>
<p className="font-semibold text-slate-800">{cls.teacher}</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<Clock size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Dias de Aula</p>
<p className="font-semibold text-slate-800 flex items-center gap-2">
{cls.scheduleDay ? DAY_NAMES[parseInt(cls.scheduleDay)] : cls.schedule}
{cls.defaultStartTime && cls.defaultEndTime && (
<span className="text-indigo-600 font-black">
{cls.defaultStartTime} às {cls.defaultEndTime}
</span>
)}
</p>
</div>
</div>
{/* Contagem de Aulas */}
{(() => {
const now = new Date();
const totalLessons = clsLessons.length;
const completedLessons = clsLessons.filter(l => {
if (l.status === 'cancelled') return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (!l.endTime) return lDate < now;
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(lDate);
lEnd.setUTCHours(eh, em, 0, 0);
return now > lEnd;
}).length;
const cancelledLessons = clsLessons.filter(l => l.status === 'cancelled').length;
const remainingLessons = totalLessons - completedLessons - cancelledLessons;
return totalLessons > 0 ? (
<div className="flex items-center gap-2 flex-wrap text-[10px] font-black">
<span className="px-2 py-1 bg-indigo-50 text-indigo-600 rounded-lg flex items-center gap-1">
<Calendar size={10} /> {totalLessons} Total
</span>
<span className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded-lg">
{completedLessons} Concluídas
</span>
<span className="px-2 py-1 bg-amber-50 text-amber-600 rounded-lg">
{remainingLessons} Restantes
</span>
</div>
) : null;
})()}
</div>
<div className="space-y-2">
<div className="flex justify-between items-end text-xs font-bold text-slate-500 px-1">
<span>OCUPAÇÃO</span>
<span>{studentCount} / {cls.maxStudents}</span>
</div>
<div className="w-full bg-slate-100 h-3 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${
occupancyPercent > 90 ? 'bg-red-500' : occupancyPercent > 50 ? 'bg-indigo-500' : 'bg-emerald-500'
}`}
style={{ width: `${occupancyPercent}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-5">
<button
onClick={() => setViewingStudentsClass(cls)}
className="bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all"
>
<User size={18} /> Ver Alunos
</button>
<button
onClick={() => setScheduleClass(cls)}
className="bg-indigo-50 border border-indigo-100 text-indigo-700 hover:bg-indigo-600 hover:text-white py-3 rounded-xl font-black flex items-center justify-center gap-2 transition-all shadow-sm group-hover:shadow-md"
>
<Calendar size={18} /> Cronograma
</button>
</div>
</div>
);
})}
{data.classes.length === 0 && (
<div className="col-span-full py-20 text-center text-slate-400 border-4 border-dashed border-slate-200 rounded-xl">
<Book size={48} className="mx-auto mb-4 opacity-10" />
<p className="font-bold text-lg">Nenhuma turma cadastrada ainda.</p>
<p className="text-sm">Vincule um curso a uma nova turma para começar.</p>
</div>
)}
</div>
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">
{editingClass ? 'Editar Turma' : 'Criar Turma'}
</h3>
<p className="text-sm text-slate-500">Selecione o curso e horários.</p>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome da Turma</label>
<input required className={inputClass} placeholder="Ex: TURMA A - NOITE"
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Curso Vinculado</label>
<select required className={inputClass}
value={formData.courseId} onChange={e => setFormData({...formData, courseId: e.target.value})}>
<option value="">Selecione um curso...</option>
{data.courses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Início</label>
<input type="date" required className={inputClass}
value={formData.startDate} onChange={e => setFormData({...formData, startDate: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Fim (Automática)</label>
<input type="date" required className={inputClass}
value={formData.endDate} onChange={e => setFormData({...formData, endDate: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Dia da Semana</label>
<select required className={inputClass}
value={formData.scheduleDay} onChange={e => setFormData({...formData, scheduleDay: e.target.value})}>
<option value="">Selecione...</option>
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Início (Hora)</label>
<input type="time" required className={inputClass}
value={formData.defaultStartTime} onChange={e => setFormData({...formData, defaultStartTime: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Término (Hora)</label>
<input type="time" required className={inputClass}
value={formData.defaultEndTime} onChange={e => setFormData({...formData, defaultEndTime: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Vagas Locais</label>
<input type="number" required className={inputClass}
value={formData.maxStudents} onChange={e => setFormData({...formData, maxStudents: parseInt(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Professor Responsável</label>
<select required className={inputClass}
value={formData.teacher} onChange={e => setFormData({...formData, teacher: e.target.value})}>
<option value="">Selecione um professor...</option>
{(data.employees || [])
.filter(e => {
const catName = (data.employeeCategories || []).find(c => c.id === e.categoryId)?.name?.toLowerCase() || '';
return catName.includes('professor') || catName.includes('prof');
})
.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
))}
{formData.teacher && !(data.employees || []).some(e => e.name === formData.teacher) && (
<option value={formData.teacher}>{formData.teacher} (Manual)</option>
)}
</select>
</div>
</div>
<div className="pt-4 flex gap-4 border-t border-slate-100">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold">
Cancelar
</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold">
{editingClass ? 'Atualizar e Sincronizar Calendário' : 'Criar Turma e Gerar Calendário'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Lesson Schedule Modal */}
{scheduleClass && (
<LessonSchedule
classObj={scheduleClass}
data={data}
updateData={updateData}
onClose={() => setScheduleClass(null)}
/>
)}
{/* Viewing Students Modal */}
{viewingStudentsClass && (
<div className="fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto animate-in fade-in">
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl my-auto relative overflow-hidden animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">Alunos da Turma</h3>
<p className="text-sm text-slate-500 mt-1">{viewingStudentsClass.name} {data.students.filter(s => s.classId === viewingStudentsClass.id).length} alunos matriculados</p>
</div>
<button
onClick={() => setViewingStudentsClass(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90"
>
<X size={24} />
</button>
</div>
<div className="p-4 max-h-[60vh] overflow-y-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-100 text-[10px] uppercase text-slate-400 font-black tracking-widest">
<th className="p-4">Aluno</th>
<th className="p-4">Idade</th>
<th className="p-4 text-right">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{data.students
.filter(s => s.classId === viewingStudentsClass.id)
.sort((a,b) => (a.name || '').localeCompare(b.name || ''))
.map(student => (
<tr key={student.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
{student.photo ? (
<img src={student.photo} alt={student.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-300">
<User size={20} />
</div>
)}
</div>
<div>
<p className="font-bold text-slate-800 text-sm">{student.name}</p>
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">{student.enrollmentNumber || 'Sem matrícula'}</p>
</div>
</div>
</td>
<td className="p-4 text-sm font-bold text-slate-600">
{calculateAge(student.birthDate) !== null ? `${calculateAge(student.birthDate)} anos` : '-'}
</td>
<td className="p-4 text-right">
<button
onClick={() => onNavigateToClass(viewingStudentsClass.id, student.id)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg text-xs font-bold hover:bg-indigo-600 hover:text-white transition-all shadow-sm"
>
Ver Perfil
</button>
</td>
</tr>
))}
{data.students.filter(s => s.classId === viewingStudentsClass.id).length === 0 && (
<tr>
<td colSpan={3} className="p-10 text-center text-slate-400 italic text-sm">Nenhum aluno matriculado nesta turma.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={() => setViewingStudentsClass(null)}
className="px-6 py-2.5 bg-white border border-slate-200 text-slate-600 font-bold hover:bg-slate-50 rounded-xl transition-colors shadow-sm"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Classes;

View File

@ -0,0 +1,498 @@
import React, { useState, useEffect } from 'react';
import { SchoolData, Contract, Student, Payment } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Search, Trash2, X, User, Calendar, FileSignature, ListChecks, Printer, AlertTriangle, RefreshCw, Edit2, Info } from 'lucide-react';
import { pdfService } from '../services/pdfService';
interface ContractsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [activeTab, setActiveTab] = useState<'contracts' | 'templates'>('contracts');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isGeneratingPDF, setIsGeneratingPDF] = useState<string | null>(null);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [contractToGenerate, setContractToGenerate] = useState<Contract | null>(null);
const [genConfig, setGenConfig] = useState({
startDate: new Date().toLocaleDateString('pt-BR'),
installments: 12,
discount: 0
});
const [formData, setFormData] = useState<Omit<Contract, 'id' | 'createdAt'>>({
studentId: '',
title: '',
content: ''
});
const [templateFormData, setTemplateFormData] = useState({
id: '',
name: '',
content: ''
});
// Pre-load content when student is selected based on template
useEffect(() => {
if (formData.studentId && !formData.content) {
const student = data.students.find(s => s.id === formData.studentId);
const cls = data.classes.find(c => c.id === student?.classId);
const course = data.courses.find(c => c.id === cls?.courseId);
const templateObj = data.contractTemplates?.find(t => t.id === student?.contractTemplateId);
if (student && course) {
let template = templateObj?.content || '';
// Aluno
template = template.replace(/{{aluno}}/g, student.name || '');
template = template.replace(/{{aluno_cpf}}/g, student.cpf || '');
template = template.replace(/{{aluno_rg}}/g, student.rg || '');
template = template.replace(/{{aluno_nascimento}}/g, student.birthDate ? new Date(student.birthDate).toLocaleDateString('pt-BR') : '');
template = template.replace(/{{aluno_email}}/g, student.email || '');
template = template.replace(/{{aluno_telefone}}/g, student.phone || '');
template = template.replace(/{{aluno_cep}}/g, student.addressZip || '');
template = template.replace(/{{aluno_endereco}}/g, `${student.addressStreet || ''}, ${student.addressNumber || ''}`);
template = template.replace(/{{aluno_bairro}}/g, student.addressNeighborhood || '');
template = template.replace(/{{aluno_cidade}}/g, student.addressCity || '');
template = template.replace(/{{aluno_estado}}/g, student.addressState || '');
// Responsável
template = template.replace(/{{responsavel_nome}}/g, student.guardianName || '');
template = template.replace(/{{responsavel_cpf}}/g, student.guardianCpf || '');
template = template.replace(/{{responsavel_nascimento}}/g, student.guardianBirthDate ? new Date(student.guardianBirthDate).toLocaleDateString('pt-BR') : '');
// Curso e Turma
template = template.replace(/{{curso}}/g, course.name || '');
template = template.replace(/{{mensalidade}}/g, course.monthlyFee ? `R$ ${course.monthlyFee.toFixed(2)}` : 'R$ 0,00');
template = template.replace(/{{duracao}}/g, course.duration || '');
template = template.replace(/{{curso_taxa_matricula}}/g, course.registrationFee ? `R$ ${course.registrationFee.toFixed(2)}` : 'R$ 0,00');
template = template.replace(/{{turma_nome}}/g, cls?.name || '');
template = template.replace(/{{turma_professor}}/g, cls?.teacher || '');
template = template.replace(/{{turma_horario}}/g, cls?.schedule || '');
// Escola
template = template.replace(/{{data}}/g, new Date().toLocaleDateString('pt-BR'));
template = template.replace(/{{escola}}/g, data.profile.name || '');
template = template.replace(/{{cnpj_escola}}/g, data.profile.cnpj || '');
setFormData(prev => ({
...prev,
content: template,
title: prev.title || `Contrato de Matrícula - ${student.name}`
}));
}
}
}, [formData.studentId, data]);
const filteredContracts = data.contracts.filter(c => {
const student = data.students.find(s => s.id === c.studentId);
const search = (searchTerm || '').toLowerCase();
return (c.title || '').toLowerCase().includes(search) || (student?.name || '').toLowerCase().includes(search);
});
const filteredTemplates = (data.contractTemplates || []).filter(t =>
(t.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.studentId || !formData.title || !formData.content) {
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e preencha o título e conteúdo do contrato.', 'warning');
return;
}
const newContract: Contract = {
...formData,
id: crypto.randomUUID(),
createdAt: new Date().toISOString()
};
updateData({ contracts: [...data.contracts, newContract] });
closeModal();
};
const handleTemplateSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!templateFormData.name || !templateFormData.content) {
showAlert('Atenção', '⚠️ Preencha o nome e o conteúdo do modelo.', 'warning');
return;
}
const templates = data.contractTemplates || [];
let updatedTemplates;
if (templateFormData.id) {
updatedTemplates = templates.map(t => t.id === templateFormData.id ? templateFormData : t);
} else {
updatedTemplates = [...templates, { ...templateFormData, id: crypto.randomUUID() }];
}
updateData({ contractTemplates: updatedTemplates });
closeTemplateModal();
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setShowGenerateModal(false);
setIsClosing(false);
setFormData({ studentId: '', title: '', content: '' });
setContractToGenerate(null);
}, 400);
};
const closeTemplateModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsTemplateModalOpen(false);
setIsClosing(false);
setTemplateFormData({ id: '', name: '', content: '' });
}, 400);
};
const handleDelete = (id: string) => {
showConfirm(
'Excluir Contrato',
'Tem certeza que deseja excluir este contrato?',
() => {
updateData({ contracts: data.contracts.filter(c => c.id !== id) });
}
);
};
const handleDeleteTemplate = (id: string) => {
showConfirm(
'Excluir Modelo',
'Tem certeza que deseja excluir este modelo de contrato?',
() => {
updateData({ contractTemplates: (data.contractTemplates || []).filter(t => t.id !== id) });
}
);
};
const handleDownloadContract = async (contract: Contract, student: Student) => {
setIsGeneratingPDF(contract.id);
try {
await pdfService.generateContractPDF(contract, student, data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(null);
}
};
const openGenerateModal = (contract: Contract) => {
const student = data.students.find(s => s.id === contract.studentId);
const cls = data.classes.find(c => c.id === student?.classId);
const course = data.courses.find(c => c.id === cls?.courseId);
if (student && cls && course) {
setContractToGenerate(contract);
setGenConfig({
startDate: new Date().toLocaleDateString('pt-BR'),
installments: course.durationMonths || 12,
discount: 0
});
setShowGenerateModal(true);
} else {
console.warn("Missing data for generation");
}
};
const handleGenerate = () => {
if (!contractToGenerate) return;
const contract = contractToGenerate;
const student = data.students.find(s => s.id === contract.studentId);
const cls = data.classes.find(c => c.id === student?.classId);
const course = data.courses.find(c => c.id === cls?.courseId);
if (!course || !student) return;
// Parse date from DD/MM/YYYY
const [d, m, y] = genConfig.startDate.split('/');
const startDate = new Date(`${y}-${m}-${d}`);
const newPayments: Payment[] = [];
for (let i = 0; i < genConfig.installments; i++) {
const dueDate = new Date(startDate);
dueDate.setMonth(startDate.getMonth() + i);
const finalAmount = Math.max(0, course.monthlyFee - genConfig.discount);
newPayments.push({
id: crypto.randomUUID(),
studentId: student.id,
contractId: contract.id,
amount: finalAmount,
discount: genConfig.discount,
dueDate: dueDate.toISOString().split('T')[0],
status: 'pending',
type: 'monthly',
installmentNumber: i + 1,
totalInstallments: genConfig.installments,
description: `Parcela ${i + 1}/${genConfig.installments} - ${course.name}${genConfig.discount > 0 ? ` (Desc: R$ ${genConfig.discount})` : ''}`
});
}
updateData({ payments: [...data.payments, ...newPayments] });
closeModal();
};
const formatDateMask = (val: string) => {
return val.replace(/\D/g, '').replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d)/, '$1/$2').slice(0, 10);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Contratos</h2>
<p className="text-slate-500 text-sm">Gestão de termos de adesão e modelos de contrato.</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => {
setTemplateFormData({ id: '', name: '', content: '' });
setIsTemplateModalOpen(true);
}}
className="flex-1 sm:flex-none bg-white border border-slate-200 text-slate-700 px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-slate-50 transition-all shadow-sm font-bold"
>
<Plus size={20} /> Novo Modelo
</button>
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 sm:flex-none bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Contrato
</button>
</div>
</div>
<div className="flex border-b border-slate-200">
<button
onClick={() => setActiveTab('contracts')}
className={`px-6 py-3 font-bold text-sm transition-all border-b-2 ${activeTab === 'contracts' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Contratos Emitidos
</button>
<button
onClick={() => setActiveTab('templates')}
className={`px-6 py-3 font-bold text-sm transition-all border-b-2 ${activeTab === 'templates' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Modelos de Contrato
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-xl overflow-hidden">
<div className="p-6 border-b border-slate-100 bg-slate-50/30">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input type="text" placeholder={activeTab === 'contracts' ? "Buscar por título ou aluno..." : "Buscar por nome do modelo..."} className={`${inputClass} pl-12`} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
</div>
</div>
{activeTab === 'contracts' ? (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Documento / Beneficiário</th>
<th className="px-6 py-4">Data Emissão</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredContracts.map(contract => {
const student = data.students.find(s => s.id === contract.studentId);
const hasPayments = data.payments.some(p => p.contractId === contract.id);
return (
<tr key={contract.id} className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-5">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${hasPayments ? 'bg-emerald-50 text-emerald-600' : 'bg-indigo-50 text-indigo-600'}`}><FileSignature size={24} /></div>
<div>
<div className="font-bold text-slate-900">{contract.title}</div>
<div className="text-xs text-slate-500 flex items-center gap-1 mt-1"><User size={12} /> {student?.name || 'Aluno Removido'} {hasPayments && <span className="ml-2 text-emerald-600 font-bold flex items-center gap-1"><ListChecks size={12}/> Financeiro Gerado</span>}</div>
</div>
</div>
</td>
<td className="px-6 py-5 text-slate-600 text-sm font-medium"><div className="flex items-center gap-2"><Calendar size={16} className="text-slate-400" /> {new Date(contract.createdAt).toLocaleDateString('pt-BR')}</div></td>
<td className="px-6 py-5 text-right flex justify-end gap-2">
<button
onClick={() => handleDownloadContract(contract, student!)}
disabled={isGeneratingPDF === contract.id}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-indigo-600 rounded-xl transition-all shadow-sm disabled:opacity-50"
title="Imprimir Contrato"
>
{isGeneratingPDF === contract.id ? (
<RefreshCw size={20} className="animate-spin" />
) : (
<Printer size={20} />
)}
</button>
<button onClick={() => handleDelete(contract.id)} className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-red-600 rounded-xl transition-all shadow-sm" title="Excluir"><Trash2 size={20} /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Nome do Modelo</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredTemplates.map(template => (
<tr key={template.id} className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-5">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-indigo-50 text-indigo-600"><FileSignature size={24} /></div>
<div className="font-bold text-slate-900">{template.name}</div>
</div>
</td>
<td className="px-6 py-5 text-right flex justify-end gap-2">
<button
onClick={() => {
setTemplateFormData(template);
setIsTemplateModalOpen(true);
}}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-indigo-600 rounded-xl transition-all shadow-sm"
title="Editar Modelo"
>
<Edit2 size={20} />
</button>
<button onClick={() => handleDeleteTemplate(template.id)} className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-red-600 rounded-xl transition-all shadow-sm" title="Excluir"><Trash2 size={20} /></button>
</td>
</tr>
))}
{filteredTemplates.length === 0 && (
<tr>
<td colSpan={2} className="px-6 py-10 text-center text-slate-400 italic">Nenhum modelo de contrato encontrado.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* CREATE CONTRACT MODAL */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">Criar Contrato Manual</h3><p className="text-sm text-slate-500">O conteúdo será preenchido pelo modelo vinculado ao aluno.</p></div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<select required className={inputClass} value={formData.studentId} onChange={e => setFormData({...formData, studentId: e.target.value})}>
<option value="">Selecione o Aluno...</option>
{data.students.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<input required placeholder="Título do Documento" className={inputClass} value={formData.title} onChange={e => setFormData({...formData, title: e.target.value})} />
</div>
<textarea required rows={10} placeholder="Conteúdo do Contrato..." className={`${inputClass} font-serif text-sm leading-relaxed resize-none`} value={formData.content} onChange={e => setFormData({...formData, content: e.target.value})} />
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl shadow-lg font-bold">Salvar Contrato</button>
</div>
</form>
</div>
</div>
)}
{/* CREATE/EDIT TEMPLATE MODAL */}
{isTemplateModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-3xl overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">{templateFormData.id ? 'Editar Modelo' : 'Novo Modelo de Contrato'}</h3><p className="text-sm text-slate-500">Defina as cláusulas e use placeholders para dados dinâmicos.</p></div>
<button onClick={closeTemplateModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleTemplateSubmit} className="p-8 space-y-5">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Nome do Modelo</label>
<input required placeholder="Ex: Contrato de Matrícula Padrão" className={inputClass} value={templateFormData.name} onChange={e => setTemplateFormData({...templateFormData, name: e.target.value})} />
</div>
<div className="bg-amber-50 border border-amber-100 p-4 rounded-lg flex gap-3">
<Info className="text-amber-500 shrink-0" size={20} />
<div className="text-xs text-amber-800 space-y-2">
<p className="font-bold">Placeholders Disponíveis:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<p><code className="bg-white px-1 rounded">{"{{aluno}}"}</code>, <code className="bg-white px-1 rounded">{"{{aluno_cpf}}"}</code>, <code className="bg-white px-1 rounded">{"{{aluno_rg}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{responsavel_nome}}"}</code>, <code className="bg-white px-1 rounded">{"{{responsavel_cpf}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{curso}}"}</code>, <code className="bg-white px-1 rounded">{"{{mensalidade}}"}</code>, <code className="bg-white px-1 rounded">{"{{duracao}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{escola}}"}</code>, <code className="bg-white px-1 rounded">{"{{cnpj_escola}}"}</code>, <code className="bg-white px-1 rounded">{"{{data}}"}</code></p>
</div>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Conteúdo do Contrato</label>
<textarea required rows={12} placeholder="Digite as cláusulas do contrato..." className={`${inputClass} font-serif text-sm leading-relaxed resize-none`} value={templateFormData.content} onChange={e => setTemplateFormData({...templateFormData, content: e.target.value})} />
</div>
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeTemplateModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl shadow-lg font-bold">Salvar Modelo</button>
</div>
</form>
</div>
</div>
)}
{/* GENERATE INSTALLMENTS MODAL */}
{showGenerateModal && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-md overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-emerald-600 w-full"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-emerald-50">
<div><h3 className="text-xl font-black text-slate-800">Gerar Financeiro</h3></div>
<button onClick={closeModal} className="p-2 text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Data 1ª Parcela</label>
<input className={inputClass} value={genConfig.startDate} onChange={e => setGenConfig({...genConfig, startDate: formatDateMask(e.target.value)})} placeholder="DD/MM/AAAA" maxLength={10} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Qtd. Parcelas</label>
<input type="number" className={inputClass} value={genConfig.installments} onChange={e => setGenConfig({...genConfig, installments: parseInt(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Desconto Mensal (R$)</label>
<input type="number" className={inputClass} value={genConfig.discount} onChange={e => setGenConfig({...genConfig, discount: parseFloat(e.target.value)})} />
</div>
<div className="pt-2 flex gap-3">
<button onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-lg font-bold text-slate-500 hover:bg-slate-50">Cancelar</button>
<button onClick={handleGenerate} className="flex-1 py-3 bg-emerald-600 text-white rounded-lg font-bold hover:bg-emerald-700">Confirmar</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Contracts;

View File

@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { SchoolData, Course } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Clock, DollarSign, BookText, Info, AlertTriangle } from 'lucide-react';
interface CoursesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Courses: React.FC<CoursesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [formData, setFormData] = useState<Omit<Course, 'id'>>({
name: '',
duration: '',
durationMonths: 12, // Default value
registrationFee: 0,
monthlyFee: 0,
description: '',
finePercentage: 0,
interestPercentage: 0
});
const extractMonths = (text: string): number => {
const match = text.match(/\d+/);
return match ? parseInt(match[0]) : 12;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.duration || formData.monthlyFee <= 0) {
showAlert('Atenção', '⚠️ Por favor, preencha o nome, duração e valor da mensalidade.', 'warning');
return;
}
// Auto-calculate months from text if possible, otherwise keep default or existing
const calculatedMonths = extractMonths(formData.duration);
const finalData = {
...formData,
durationMonths: calculatedMonths
};
if (editingCourse) {
const updated = data.courses.map(c => c.id === editingCourse.id ? { ...finalData, id: c.id } : c);
updateData({ courses: updated });
} else {
const newCourse: Course = { ...finalData, id: crypto.randomUUID() };
updateData({ courses: [...data.courses, newCourse] });
}
closeModal();
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingCourse(null);
setFormData({ name: '', duration: '', durationMonths: 12, registrationFee: 0, monthlyFee: 0, description: '', finePercentage: 0, interestPercentage: 0 });
}, 400);
};
const handleEdit = (course: Course) => {
setEditingCourse(course);
setFormData({
name: course.name || '',
duration: course.duration || '',
durationMonths: course.durationMonths || 12,
registrationFee: course.registrationFee || 0,
monthlyFee: course.monthlyFee || 0,
description: course.description || '',
finePercentage: course.finePercentage || 0,
interestPercentage: course.interestPercentage || 0
});
setIsModalOpen(true);
};
const checkAndDelete = (id: string) => {
const hasClasses = data.classes.some(c => c.courseId === id);
if (hasClasses) {
showAlert('Atenção', 'Não é possível excluir um curso que possui turmas vinculadas.', 'warning');
return;
}
showConfirm(
'Excluir Curso',
'Tem certeza que deseja excluir este curso?',
() => {
updateData({ courses: data.courses.filter(c => c.id !== id) });
}
);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Cursos</h2>
<p className="text-slate-500">Gerencie os cursos oferecidos pela escola.</p>
</div>
<button onClick={() => setIsModalOpen(true)} className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"><Plus size={20} /> Novo Curso</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.courses.map(course => (
<div key={course.id} className="bg-white p-7 rounded-xl border border-slate-200 shadow-sm hover:shadow-xl transition-all group flex flex-col h-full relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 bg-white/80 backdrop-blur-sm rounded-bl-2xl">
<button onClick={() => handleEdit(course)} className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"><Edit2 size={16} /></button>
<button onClick={() => checkAndDelete(course.id)} className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"><Trash2 size={16} /></button>
</div>
<div className="mb-6">
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600 mb-4">
<BookText size={24} />
</div>
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{course.name}</h3>
<p className="text-sm text-slate-500 line-clamp-2">{course.description || 'Sem descrição definida.'}</p>
</div>
<div className="space-y-3 mt-auto">
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<Clock size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Duração</p>
<p className="font-semibold text-slate-800">{course.duration}</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<DollarSign size={18} className="text-emerald-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Investimento Mensal</p>
<p className="font-semibold text-slate-800">R$ {course.monthlyFee.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</p>
</div>
</div>
</div>
</div>
))}
</div>
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">{editingCourse ? 'Editar Curso' : 'Novo Curso'}</h3><p className="text-sm text-slate-500">Defina os detalhes e valores do curso.</p></div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome do Curso</label>
<input required className={inputClass} placeholder="Ex: Informática Básica" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Descrição</label>
<textarea rows={3} className={inputClass} placeholder="Breve resumo do conteúdo..." value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} />
</div>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Duração</label>
<input required className={inputClass} placeholder="Ex: 12 meses" value={formData.duration} onChange={e => setFormData({...formData, duration: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Taxa de Matrícula (R$)</label>
<input type="number" required min="0" step="0.01" className={inputClass} value={formData.registrationFee} onChange={e => setFormData({...formData, registrationFee: parseFloat(e.target.value)})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Mensalidade (R$)</label>
<input type="number" required min="0" step="0.01" className={inputClass} value={formData.monthlyFee} onChange={e => setFormData({...formData, monthlyFee: parseFloat(e.target.value)})} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Multa por Atraso (%)</label>
<input type="number" min="0" step="0.01" className={inputClass} value={formData.finePercentage} onChange={e => setFormData({...formData, finePercentage: parseFloat(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Juros ao Mês (%)</label>
<input type="number" min="0" step="0.01" className={inputClass} value={formData.interestPercentage} onChange={e => setFormData({...formData, interestPercentage: parseFloat(e.target.value) || 0})} />
</div>
</div>
</div>
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold">Salvar Curso</button>
</div>
</form>
</div>
</div>
)}
{/* DELETE CONFIRM MODAL */}
</div>
);
};
export default Courses;

View File

@ -0,0 +1,464 @@
import React, { useState, useMemo } from 'react';
import { SchoolData, Student, Payment, Class } from '../types';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
PieChart,
Pie,
AreaChart,
Area,
Legend,
LineChart,
Line
} from 'recharts';
import {
Users,
BookOpen,
Wallet,
Clock,
FileDown,
RefreshCw,
TrendingUp,
UserPlus,
CheckCircle2,
AlertCircle,
Calendar,
ChevronRight,
Layout
} from 'lucide-react';
import { pdfService } from '../services/pdfService';
interface DashboardProps {
data: SchoolData;
}
const Dashboard: React.FC<DashboardProps> = ({ data }) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [dashboardView, setDashboardView] = useState<'standard' | 'detailed'>('standard');
// Basic Stats
const activeStudents = useMemo(() => data.students.filter(s => s.status === 'active').length, [data.students]);
const totalClasses = useMemo(() => data.classes.length, [data.classes]);
const pendingPayments = useMemo(() => data.payments.filter(p => p.status === 'pending').length, [data.payments]);
const revenue = useMemo(() => data.payments
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount, 0), [data.payments]);
// Advanced Stats
const newStudentsThisMonth = useMemo(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return data.students.filter(s => new Date(s.registrationDate) >= startOfMonth).length;
}, [data.students]);
const attendanceRate = useMemo(() => {
if (!data.attendance || data.attendance.length === 0) return 0;
const presents = data.attendance.filter(a => a.type === 'presence').length;
return Math.round((presents / data.attendance.length) * 100);
}, [data.attendance]);
const averagePaymentValue = useMemo(() => {
if (data.payments.length === 0) return 0;
const total = data.payments.reduce((sum, p) => sum + p.amount, 0);
return Math.round(total / data.payments.length);
}, [data.payments]);
const aulasARepor = useMemo(() => {
if (!data.lessons) return 0;
const cancelled = data.lessons.filter(l => l.status === 'cancelled');
return cancelled.filter(c => !data.lessons!.some(l => l.originalLessonId === c.id)).length;
}, [data.lessons]);
// Chart Data: Class Occupancy
const classOccupancy = useMemo(() => data.classes.map(c => ({
name: c.name,
students: data.students.filter(s => s.classId === c.id).length,
capacity: 20 // Assuming a default capacity
})).sort((a, b) => b.students - a.students), [data.classes, data.students]);
// Chart Data: Payment Status
const paymentStatus = useMemo(() => [
{ name: 'Pago', value: data.payments.filter(p => p.status === 'paid').length, color: '#10b981' },
{ name: 'Pendente', value: data.payments.filter(p => p.status === 'pending').length, color: '#f59e0b' },
{ name: 'Atrasado', value: data.payments.filter(p => p.status === 'overdue').length, color: '#ef4444' },
], [data.payments]);
// Chart Data: Revenue Over Time (Last 6 months)
const revenueHistory = useMemo(() => {
const months = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const now = new Date();
const history = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthName = months[d.getMonth()];
const monthPayments = data.payments.filter(p => {
const pDate = new Date(p.paidDate || p.dueDate);
return pDate.getMonth() === d.getMonth() && pDate.getFullYear() === d.getFullYear() && p.status === 'paid';
});
const monthRevenue = monthPayments.reduce((sum, p) => sum + p.amount, 0);
history.push({ name: monthName, revenue: monthRevenue });
}
return history;
}, [data.payments]);
// Recent Activity
const recentActivity = useMemo(() => {
const activities = [
...data.students.slice(-3).map(s => ({
type: 'student',
title: 'Novo Aluno',
desc: s.name,
date: s.registrationDate,
icon: UserPlus,
color: 'bg-blue-100 text-blue-600'
})),
...data.payments.filter(p => p.status === 'paid').slice(-3).map(p => ({
type: 'payment',
title: 'Pagamento Recebido',
desc: `R$ ${p.amount.toLocaleString()}`,
date: p.paidDate || p.dueDate,
icon: CheckCircle2,
color: 'bg-emerald-100 text-emerald-600'
}))
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
return activities;
}, [data.students, data.payments]);
const handleGenerateReport = async () => {
setIsGeneratingPDF(true);
try {
await pdfService.generateFullSchoolReportPDF(data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(false);
}
};
const stats = [
{ label: 'Alunos Ativos', value: activeStudents, icon: Users, color: 'text-blue-600', bg: 'bg-blue-100', trend: '+12%' },
{ label: 'Turmas Ativas', value: totalClasses, icon: BookOpen, color: 'text-indigo-600', bg: 'bg-indigo-100', trend: '+2' },
{ label: 'Receita Total', value: `R$ ${revenue.toLocaleString()}`, icon: Wallet, color: 'text-emerald-600', bg: 'bg-emerald-100', trend: '+8.4%' },
{ label: 'Taxa de Presença', value: `${attendanceRate}%`, icon: TrendingUp, color: 'text-purple-600', bg: 'bg-purple-100', trend: '+2.1%' },
];
const secondaryStats = [
{ label: 'Aulas a Repor', value: aulasARepor, icon: Calendar, color: 'text-red-600' },
{ label: 'Novos Alunos (Mês)', value: newStudentsThisMonth, icon: UserPlus, color: 'text-sky-600' },
{ label: 'Pagamentos Pendentes', value: pendingPayments, icon: Clock, color: 'text-amber-600' },
{ label: 'Ticket Médio', value: `R$ ${averagePaymentValue}`, icon: Wallet, color: 'text-slate-600' },
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Painel Executivo</h2>
<p className="text-slate-500 font-medium">Visão geral do desempenho da instituição.</p>
</div>
<div className="flex items-center gap-3">
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setDashboardView('standard')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'standard' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Padrão
</button>
<button
onClick={() => setDashboardView('detailed')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'detailed' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Detalhado
</button>
</div>
<button
onClick={handleGenerateReport}
disabled={isGeneratingPDF}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-sm"
>
{isGeneratingPDF ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<FileDown size={18} />
)}
{isGeneratingPDF ? 'Gerando...' : 'Exportar PDF'}
</button>
</div>
</header>
{/* Main Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, i) => (
<div key={i} className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex justify-between items-start mb-4">
<div className={`${stat.bg} ${stat.color} p-3 rounded-xl group-hover:scale-110 transition-transform`}>
<stat.icon size={24} />
</div>
<span className="text-xs font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">
{stat.trend}
</span>
</div>
<div>
<p className="text-sm text-slate-500 font-bold uppercase tracking-wider mb-1">{stat.label}</p>
<h3 className="text-3xl font-black text-slate-900">{stat.value}</h3>
</div>
</div>
))}
</div>
{/* Secondary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{secondaryStats.map((stat, i) => (
<div key={i} className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`${stat.color}`}>
<stat.icon size={20} />
</div>
<p className="text-sm font-bold text-slate-600">{stat.label}</p>
</div>
<p className="text-lg font-black text-slate-900">{stat.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Revenue Area Chart */}
<div className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-8">
<div>
<h3 className="text-lg font-black text-slate-900">Fluxo de Receita</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimos 6 meses</p>
</div>
<div className="flex items-center gap-2 text-emerald-600 font-black text-sm">
<TrendingUp size={16} />
<span>+15.2% vs ano anterior</span>
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={revenueHistory}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
tickFormatter={(value) => `R$ ${value}`}
/>
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
formatter={(value: number) => [`R$ ${value.toLocaleString()}`, 'Receita']}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorRevenue)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Payment Status Pie Chart */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 className="text-lg font-black text-slate-900 mb-2">Status Financeiro</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-8">Distribuição de pagamentos</p>
<div className="h-64 relative">
{data.payments.length > 0 ? (
<>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={paymentStatus}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={90}
paddingAngle={8}
dataKey="value"
stroke="none"
>
{paymentStatus.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-3xl font-black text-slate-900">{data.payments.length}</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Total</span>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem dados</div>
)}
</div>
<div className="mt-6 space-y-3">
{paymentStatus.map((item, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{backgroundColor: item.color}}></div>
<span className="text-sm font-bold text-slate-600">{item.name}</span>
</div>
<span className="text-sm font-black text-slate-900">{item.value}</span>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Class Occupancy Bar Chart */}
<div className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-8">
<div>
<h3 className="text-lg font-black text-slate-900">Ocupação das Turmas</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Alunos por turma</p>
</div>
<button className="text-indigo-600 text-xs font-black uppercase tracking-widest hover:underline">Ver todas</button>
</div>
<div className="h-80">
{classOccupancy.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={classOccupancy} layout="vertical" margin={{left: 40}}>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#f1f5f9" />
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
axisLine={false}
tickLine={false}
tick={{fill: '#1e293b', fontSize: 11, fontWeight: 800}}
width={80}
/>
<Tooltip
cursor={{fill: '#f8fafc'}}
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
/>
<Bar dataKey="students" fill="#6366f1" radius={[0, 10, 10, 0]} barSize={20}>
{classOccupancy.map((entry, index) => (
<Cell key={`cell-${index}`} fill={index === 0 ? '#4f46e5' : '#818cf8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem turmas</div>
)}
</div>
</div>
{/* Recent Activity Feed */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 className="text-lg font-black text-slate-900 mb-6">Atividade Recente</h3>
<div className="space-y-6">
{recentActivity.length > 0 ? (
recentActivity.map((activity, i) => (
<div key={i} className="flex gap-4 relative">
{i !== recentActivity.length - 1 && (
<div className="absolute left-5 top-10 bottom-0 w-0.5 bg-slate-100 -mb-6"></div>
)}
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 z-10 ${activity.color}`}>
<activity.icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<p className="text-sm font-black text-slate-900 truncate">{activity.title}</p>
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap ml-2">
{new Date(activity.date).toLocaleDateString('pt-BR', {day: '2-digit', month: 'short'})}
</span>
</div>
<p className="text-xs text-slate-500 font-medium truncate">{activity.desc}</p>
</div>
</div>
))
) : (
<div className="text-center py-10 text-slate-400 font-bold italic">Nenhuma atividade recente</div>
)}
</div>
<button className="w-full mt-8 py-3 rounded-xl border border-slate-200 text-slate-600 text-xs font-black uppercase tracking-widest hover:bg-slate-50 transition-colors flex items-center justify-center gap-2">
Ver Log Completo
<ChevronRight size={14} />
</button>
</div>
</div>
{/* Detailed View Expansion */}
{dashboardView === 'detailed' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-bottom-4 duration-500">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 className="text-lg font-black text-slate-900 mb-6">Distribuição por Gênero</h3>
<div className="h-48 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: 'Masculino', value: data.students.filter(s => s.gender === 'M').length },
{ name: 'Feminino', value: data.students.filter(s => s.gender === 'F').length },
{ name: 'Outro', value: data.students.filter(s => s.gender === 'O').length },
]}
cx="50%"
cy="50%"
outerRadius={60}
dataKey="value"
label
>
<Cell fill="#3b82f6" />
<Cell fill="#ec4899" />
<Cell fill="#94a3b8" />
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 className="text-lg font-black text-slate-900 mb-6">Alunos por Status</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[
{ name: 'Ativo', value: data.students.filter(s => s.status === 'active').length },
{ name: 'Inativo', value: data.students.filter(s => s.status === 'inactive').length },
{ name: 'Trancado', value: data.students.filter(s => s.status === 'suspended').length },
]}>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 12, fontWeight: 700}} />
<Tooltip cursor={{fill: 'transparent'}} />
<Bar dataKey="value" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,439 @@
import React, { useState } from 'react';
import { SchoolData, Employee, EmployeeCategory } from '../types';
import { Plus, Edit2, Trash2, X, Search, Users, Briefcase, Calendar, Phone, Mail, FileText, Settings2 } from 'lucide-react';
import { useDialog } from '../DialogContext';
interface EmployeesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Employees: React.FC<EmployeesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
const [editingCategory, setEditingCategory] = useState<EmployeeCategory | null>(null);
const [formData, setFormData] = useState<Omit<Employee, 'id'>>({
name: '',
cpf: '',
phone: '',
email: '',
admissionDate: new Date().toISOString().split('T')[0],
categoryId: ''
});
const [categoryFormData, setCategoryFormData] = useState({ name: '' });
const employees = data.employees || [];
const categories = data.employeeCategories || [];
const filteredEmployees = employees.filter(emp =>
(emp.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(emp.cpf || '').includes(searchTerm || '') ||
(emp.email || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingEmployee(null);
setFormData({
name: '',
cpf: '',
phone: '',
email: '',
admissionDate: new Date().toISOString().split('T')[0],
categoryId: ''
});
}, 400);
};
const closeCategoryModal = () => {
setIsCategoryModalOpen(false);
setEditingCategory(null);
setCategoryFormData({ name: '' });
};
const handleEdit = (emp: Employee) => {
setEditingEmployee(emp);
setFormData({
name: emp.name,
cpf: emp.cpf,
phone: emp.phone,
email: emp.email,
admissionDate: emp.admissionDate,
categoryId: emp.categoryId
});
setIsModalOpen(true);
};
const handleDelete = (emp: Employee) => {
showConfirm(
'Remover Funcionário',
`Tem certeza que deseja remover ${emp.name}?`,
() => {
const updatedEmployees = employees.filter(e => e.id !== emp.id);
updateData({ employees: updatedEmployees });
showAlert('Sucesso', 'Funcionário removido com sucesso.', 'success');
}
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.categoryId) {
showAlert('Atenção', 'Selecione uma categoria para o funcionário.', 'warning');
return;
}
if (editingEmployee) {
const updatedEmployees = employees.map(emp =>
emp.id === editingEmployee.id ? { ...formData, id: emp.id } : emp
);
updateData({ employees: updatedEmployees });
showAlert('Sucesso', 'Funcionário atualizado com sucesso.', 'success');
} else {
const newEmployee: Employee = {
...formData,
id: crypto.randomUUID()
};
updateData({ employees: [...employees, newEmployee] });
showAlert('Sucesso', 'Funcionário cadastrado com sucesso.', 'success');
}
closeModal();
};
const handleCategorySubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!categoryFormData.name.trim()) return;
if (editingCategory) {
const updatedCategories = categories.map(cat =>
cat.id === editingCategory.id ? { ...cat, name: categoryFormData.name } : cat
);
updateData({ employeeCategories: updatedCategories });
} else {
const newCategory: EmployeeCategory = {
id: crypto.randomUUID(),
name: categoryFormData.name
};
updateData({ employeeCategories: [...categories, newCategory] });
}
setCategoryFormData({ name: '' });
setEditingCategory(null);
};
const handleDeleteCategory = (cat: EmployeeCategory) => {
const hasEmployees = employees.some(emp => emp.categoryId === cat.id);
if (hasEmployees) {
showAlert('Atenção', 'Não é possível excluir uma categoria que possui funcionários vinculados.', 'warning');
return;
}
showConfirm(
'Remover Categoria',
`Deseja remover a categoria "${cat.name}"?`,
() => {
const updatedCategories = categories.filter(c => c.id !== cat.id);
updateData({ employeeCategories: updatedCategories });
}
);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Funcionários</h2>
<p className="text-slate-500">Gerencie sua equipe e categorias profissionais.</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
<button
onClick={() => setIsCategoryModalOpen(true)}
className="flex-1 md:flex-none bg-white text-slate-700 px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-slate-50 transition-all shadow-sm border border-slate-200 font-bold"
>
<Settings2 size={20} /> Categorias
</button>
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 md:flex-none bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Funcionário
</button>
</div>
</div>
{/* Search and Stats */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 relative group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={20} />
<input
type="text"
placeholder="Buscar por nome, CPF ou e-mail..."
className="w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600">
<Users size={24} />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Equipe</p>
<p className="text-2xl font-black text-slate-900">{employees.length}</p>
</div>
</div>
</div>
{/* Employees Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredEmployees.map(emp => {
const category = categories.find(c => c.id === emp.categoryId);
return (
<div key={emp.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all group overflow-hidden">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center text-slate-500 group-hover:bg-indigo-50 group-hover:text-indigo-600 transition-colors">
<Users size={24} />
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(emp)}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(emp)}
className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"
>
<Trash2 size={18} />
</button>
</div>
</div>
<h3 className="text-lg font-black text-slate-900 mb-1">{emp.name}</h3>
<span className="inline-block px-2 py-1 bg-indigo-50 text-indigo-600 text-[10px] font-black uppercase rounded-md mb-4">
{category?.name || 'Sem Categoria'}
</span>
<div className="space-y-2 text-sm text-slate-600">
<div className="flex items-center gap-2">
<FileText size={14} className="text-slate-400" />
<span>CPF: {emp.cpf}</span>
</div>
<div className="flex items-center gap-2">
<Phone size={14} className="text-slate-400" />
<span>{emp.phone}</span>
</div>
<div className="flex items-center gap-2">
<Mail size={14} className="text-slate-400" />
<span className="truncate">{emp.email}</span>
</div>
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400" />
<span>Admissão: {new Date(emp.admissionDate).toLocaleDateString('pt-BR')}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
{employees.length === 0 && (
<div className="bg-white border-2 border-dashed border-slate-200 rounded-3xl p-12 text-center">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 text-slate-300">
<Users size={40} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">Nenhum funcionário cadastrado</h3>
<p className="text-slate-500 mb-6">Comece adicionando os membros da sua equipe.</p>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg"
>
Cadastrar Primeiro Funcionário
</button>
</div>
)}
{/* Employee Modal */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800 tracking-tight">
{editingEmployee ? 'Editar Funcionário' : 'Novo Funcionário'}
</h3>
<p className="text-xs text-slate-500">Preencha os dados profissionais.</p>
</div>
<button onClick={closeModal} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome Completo</label>
<input
required
className={inputClass}
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">CPF</label>
<input
required
className={inputClass}
placeholder="000.000.000-00"
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({ ...formData, cpf: val });
}}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Categoria</label>
<select
required
className={inputClass}
value={formData.categoryId}
onChange={e => setFormData({ ...formData, categoryId: e.target.value })}
>
<option value="">Selecione...</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Telefone</label>
<input
required
className={inputClass}
placeholder="(00) 00000-0000"
value={formData.phone}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d)/, '$1-$2').slice(0, 15);
setFormData({ ...formData, phone: val });
}}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">E-mail</label>
<input
type="email"
required
className={inputClass}
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Admissão</label>
<input
type="date"
required
className={inputClass}
value={formData.admissionDate}
onChange={e => setFormData({ ...formData, admissionDate: e.target.value })}
/>
</div>
<div className="md:col-span-2 pt-4 flex gap-3">
<button type="button" onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 font-bold text-sm">
Cancelar
</button>
<button type="submit" className="flex-1 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 shadow-lg font-bold text-sm">
{editingEmployee ? 'Atualizar' : 'Cadastrar'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Categories Modal */}
{isCategoryModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-200">
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl relative overflow-hidden animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-xl font-black text-slate-800 tracking-tight">Gerenciar Categorias</h3>
<button onClick={closeCategoryModal} className="p-2 text-slate-400 hover:text-red-500 transition-all">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-6">
<form onSubmit={handleCategorySubmit} className="flex gap-2">
<input
placeholder="Nova categoria (ex: Professor)"
className={`${inputClass} flex-1`}
value={categoryFormData.name}
onChange={e => setCategoryFormData({ name: e.target.value })}
/>
<button type="submit" className="bg-indigo-600 text-white p-3 rounded-xl hover:bg-indigo-700 transition-all shadow-md">
<Plus size={20} />
</button>
</form>
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
{categories.map(cat => (
<div key={cat.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-xl group">
<span className="font-bold text-slate-700">{cat.name}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditingCategory(cat);
setCategoryFormData({ name: cat.name });
}}
className="p-1.5 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-white transition-all"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDeleteCategory(cat)}
className="p-1.5 text-slate-400 hover:text-red-600 rounded-lg hover:bg-white transition-all"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{categories.length === 0 && (
<p className="text-center text-slate-400 text-sm py-4 italic">Nenhuma categoria cadastrada.</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Employees;

View File

@ -0,0 +1,486 @@
import React, { useState, useRef } from 'react';
import { SchoolData, Exam, Question } from '../types';
import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X } from 'lucide-react';
import { uploadExamImage } from '../services/supabase';
interface ExamsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const [searchTerm, setSearchTerm] = useState('');
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [isUploading, setIsUploading] = useState(false);
const exams = data.exams || [];
const filteredExams = exams.filter(exam =>
exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleStartCreate = () => {
setEditingExam({
id: Date.now().toString(),
title: '',
classId: data.classes[0]?.id || '',
durationMinutes: 60,
status: 'draft',
questions: []
});
setCurrentView('builder');
};
const handleEditExam = (exam: Exam) => {
setEditingExam({ ...exam });
setCurrentView('builder');
};
const handleAddQuestion = () => {
if (!editingExam) return;
setEditingExam({
...editingExam,
questions: [
...editingExam.questions,
{
id: Date.now().toString() + Math.random().toString(36).substring(7),
text: '',
options: ['', '', '', ''],
correctOptionIndex: 0
}
]
});
};
const handleRemoveQuestion = (qIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions.splice(qIndex, 1);
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleQuestionChange = (qIndex: number, field: keyof Question, value: any) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex] = { ...newQuestions[qIndex], [field]: value };
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleOptionChange = (qIndex: number, oIndex: number, value: string) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
const newOptions = [...newQuestions[qIndex].options];
newOptions[oIndex] = value;
newQuestions[qIndex].options = newOptions;
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleAddOption = (qIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex].options.push('');
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleRemoveOption = (qIndex: number, oIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex].options.splice(oIndex, 1);
// Adjust correctOptionIndex if needed
if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) {
newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1);
} else if (newQuestions[qIndex].correctOptionIndex === oIndex) {
newQuestions[qIndex].correctOptionIndex = 0;
}
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const url = await uploadExamImage(file);
if (url) {
handleQuestionChange(qIndex, 'imageUrl', url);
} else {
alert('Falha ao obter URL pública da imagem após o upload.');
}
} catch (error: any) {
console.error(error);
const errorMessage = error.message || 'Erro desconhecido';
alert(`Erro ao enviar imagem: ${errorMessage}\n\nCertifique-se de que o bucket "edumanager-assets" existe no Supabase e tem as permissões corretas.`);
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = ''; // Reset file input
}
}
};
const handleSave = (status: 'draft' | 'published') => {
if (!editingExam) return;
if (!editingExam.title || !editingExam.classId) {
alert('Preencha o título e a turma antes de salvar.');
return;
}
const finalExam = { ...editingExam, status };
const currentExams = data.exams || [];
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
let newExams;
if (existingIndex >= 0) {
newExams = [...currentExams];
newExams[existingIndex] = finalExam;
} else {
newExams = [...currentExams, finalExam];
}
updateData({ exams: newExams });
setCurrentView('list');
setEditingExam(null);
};
if (currentView === 'builder' && editingExam) {
return (
<div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32">
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => setCurrentView('list')}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
>
<ArrowLeft size={24} />
</button>
<div>
<h2 className="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
Criador de Provas
</h2>
<p className="text-slate-500 mt-1 font-medium">Configure os detalhes e as questões da avaliação.</p>
</div>
</div>
{/* Informações Básicas */}
<div className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200 mb-8">
<h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
<BookOpen className="text-indigo-500" size={20} /> Informações Básicas
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label>
<input
type="text"
value={editingExam.title}
onChange={e => setEditingExam({...editingExam, title: e.target.value})}
placeholder="Ex: Prova Bimestral de Matemática"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
<select
value={editingExam.classId}
onChange={e => setEditingExam({...editingExam, classId: e.target.value})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="" disabled>Selecione uma turma</option>
{data.classes.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label>
<input
type="number"
value={editingExam.durationMinutes}
onChange={e => setEditingExam({...editingExam, durationMinutes: parseInt(e.target.value) || 0})}
min="0"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Disciplina (Boletim)</label>
<select
value={editingExam.subjectId || ''}
onChange={e => setEditingExam({...editingExam, subjectId: e.target.value || undefined})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="">Nenhuma (não vincular)</option>
{(data.subjects || []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<p className="text-[11px] text-slate-400 mt-1.5">Vincule a uma disciplina do Boletim Escolar para lançar notas automaticamente.</p>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Período (Boletim)</label>
<select
value={editingExam.periodId || ''}
onChange={e => setEditingExam({...editingExam, periodId: e.target.value || undefined})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="">Nenhum (não vincular)</option>
{(data.periods || []).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<p className="text-[11px] text-slate-400 mt-1.5">Vincule a um período para que a nota apareça no campo correto do boletim.</p>
</div>
</div>
</div>
{/* Questões */}
<div className="space-y-6 mb-8">
{editingExam.questions.map((question, qIndex) => (
<div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up">
<div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
<div className="flex justify-between items-center mb-6">
<h4 className="text-lg font-black text-slate-800 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span>
Questão
</h4>
<button
onClick={() => handleRemoveQuestion(qIndex)}
className="text-slate-400 hover:text-red-500 transition-colors p-2"
title="Remover Questão"
>
<Trash2 size={20} />
</button>
</div>
<div className="space-y-6">
{/* Enunciado */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label>
<textarea
value={question.text}
onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)}
placeholder="Digite o enunciado da questão..."
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800 min-h-[100px] resize-y"
/>
</div>
{/* Imagem da Questão */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Imagem de Apoio (Opcional)</label>
{question.imageUrl ? (
<div className="relative inline-block mt-2 group/img">
<img src={question.imageUrl} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" />
<button
onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)}
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg"
>
<Trash2 size={16} />
</button>
</div>
) : (
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageUpload(qIndex, e)}
disabled={isUploading}
/>
{isUploading ? (
<span className="text-slate-500 font-bold text-sm">Enviando...</span>
) : (
<>
<ImageIcon size={20} className="text-slate-400" />
<span className="text-slate-500 font-bold text-sm">Adicionar Imagem</span>
</>
)}
</label>
)}
</div>
{/* Alternativas */}
<div className="pt-4 border-t border-slate-100">
<label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label>
<div className="space-y-3">
{question.options.map((option, oIndex) => (
<div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}>
<div className="flex items-center justify-center w-10">
<input
type="radio"
name={`correct-${question.id}`}
checked={question.correctOptionIndex === oIndex}
onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)}
className="w-5 h-5 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
title="Marcar como correta"
/>
</div>
<input
type="text"
value={option}
onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)}
placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`}
className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400"
/>
<button
onClick={() => handleRemoveOption(qIndex, oIndex)}
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
disabled={question.options.length <= 2}
title={question.options.length <= 2 ? "Mínimo de 2 alternativas" : "Remover alternativa"}
>
<X size={18} />
</button>
</div>
))}
</div>
<button
onClick={() => handleAddOption(qIndex)}
className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
>
<Plus size={16} /> Adicionar Alternativa
</button>
</div>
</div>
</div>
))}
{/* Botão Adicionar Questão */}
<button
onClick={handleAddQuestion}
className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group"
>
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<Plus size={24} className="text-indigo-600" />
</div>
Adicionar Nova Questão
</button>
</div>
{/* Sticky Actions Bar */}
<div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40">
<button
onClick={() => handleSave('draft')}
className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors"
>
<Save size={20} /> Salvar como Rascunho
</button>
<button
onClick={() => handleSave('published')}
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
>
<CheckCircle size={20} /> Publicar Avaliação
</button>
</div>
</div>
);
}
// LIST VIEW
return (
<div className="p-8 max-w-7xl mx-auto animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h2 className="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
<BookOpen className="text-indigo-600" size={32} /> Avaliações
</h2>
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
</div>
<div className="flex flex-col md:flex-row gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar avaliação..."
className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={handleStartCreate}
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200"
>
<Plus size={20} />
Nova Avaliação
</button>
</div>
</div>
{filteredExams.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center shadow-sm border border-slate-100 flex flex-col items-center">
<div className="w-20 h-20 bg-indigo-50 rounded-full flex items-center justify-center mb-4">
<FileText size={32} className="text-indigo-300" />
</div>
<h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3>
<p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p>
<button
onClick={handleStartCreate}
className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors"
>
Criar Minha Primeira Avaliação
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredExams.map(exam => {
const classObj = data.classes.find(c => c.id === exam.classId);
return (
<div key={exam.id} className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
<div className="flex justify-between items-start mb-4">
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 ${
exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
}`}>
{exam.status === 'published' ? 'Publicada' : 'Rascunho'}
</span>
</div>
<div className="space-y-2 mb-6">
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Turma:</span>
{classObj?.name || 'Turma não encontrada'}
</p>
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Questões:</span>
{exam.questions?.length || 0}
</p>
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Duração:</span>
{exam.durationMinutes} min
</p>
{exam.subjectId && (
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Disciplina:</span>
{(data.subjects || []).find(s => s.id === exam.subjectId)?.name || '—'}
</p>
)}
{exam.periodId && (
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Período:</span>
{(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'}
</p>
)}
</div>
<div className="border-t border-slate-100 pt-4 flex justify-end">
<button
onClick={() => handleEditExam(exam)}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
>
Editar Avaliação
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default Exams;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,648 @@
import React, { useState } from 'react';
import {
Book,
Plus,
Trash2,
CheckCircle,
XCircle,
DollarSign,
Package,
Users,
ChevronRight,
Search,
AlertCircle,
Save,
X,
RefreshCw,
Edit
} from 'lucide-react';
import { SchoolData, Handout, HandoutDelivery, Class, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { supabase, isSupabaseConfigured } from '../services/supabase';
interface HandoutsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Handouts: React.FC<HandoutsProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [showAddHandout, setShowAddHandout] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'classes' | 'individual'>('classes');
const [isSyncing, setIsSyncing] = useState(false);
const [editingHandoutId, setEditingHandoutId] = useState<string | null>(null);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setShowAddHandout(false);
setSelectedClass(null);
setSelectedStudent(null);
setIsClosing(false);
setEditingHandoutId(null);
setNewHandout({ name: '', price: 0 });
}, 400);
};
// Sync Asaas payments on mount
React.useEffect(() => {
syncAsaasPayments();
}, []);
// Auto-sync handout payment status with Finance payments
React.useEffect(() => {
if (!data.payments || !data.handoutDeliveries) return;
const currentDeliveries = data.handoutDeliveries;
const currentPayments = data.payments;
const currentHandouts = data.handouts || [];
const updatedDeliveries = currentDeliveries.map(delivery => {
if (delivery.paymentStatus === 'pending') {
const handout = currentHandouts.find(h => h.id === delivery.handoutId);
const isPaidInFinance = currentPayments.some(p =>
p.studentId === delivery.studentId &&
p.status === 'paid' &&
(
(delivery.asaasPaymentId && p.asaasPaymentId === delivery.asaasPaymentId) ||
(handout && p.description && p.description.includes(handout.name))
)
);
if (isPaidInFinance) {
return {
...delivery,
paymentStatus: 'paid' as const,
paymentDate: delivery.paymentDate || new Date().toISOString()
};
}
}
return delivery;
});
const hasChanges = updatedDeliveries.some((d, i) => d.paymentStatus !== currentDeliveries[i].paymentStatus);
if (hasChanges) {
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
}
}, [data.payments, data.handoutDeliveries, data.handouts, updateData, data]);
// Sincronização automática removida conforme pedido
const syncAsaasPayments = () => {
// Função desativada para manter compatibilidade com render e evitar erros de referência
};
// Form state for new handout
const [newHandout, setNewHandout] = useState<Partial<Handout>>({
name: '',
price: 0,
description: '',
finePercentage: 0,
interestPercentage: 0
});
const handouts = data.handouts || [];
const deliveries = data.handoutDeliveries || [];
const classes = data.classes || [];
const students = data.students || [];
const handleAddHandout = () => {
if (!newHandout.name || newHandout.price === undefined) {
showAlert('Erro', 'Por favor, preencha o nome e o preço da apostila.', 'error');
return;
}
let updatedHandouts: Handout[];
if (editingHandoutId) {
updatedHandouts = handouts.map(h =>
h.id === editingHandoutId
? {
...h,
name: newHandout.name!,
price: newHandout.price!,
description: newHandout.description,
finePercentage: newHandout.finePercentage || 0,
interestPercentage: newHandout.interestPercentage || 0
}
: h
);
showAlert('Sucesso', 'Apostila atualizada com sucesso!', 'success');
} else {
const handout: Handout = {
id: crypto.randomUUID(),
name: newHandout.name,
price: newHandout.price,
description: newHandout.description,
finePercentage: newHandout.finePercentage || 0,
interestPercentage: newHandout.interestPercentage || 0
};
updatedHandouts = [...handouts, handout];
showAlert('Sucesso', 'Apostila adicionada com sucesso!', 'success');
}
updateData({ handouts: updatedHandouts });
dbService.saveData({ ...data, handouts: updatedHandouts });
setNewHandout({ name: '', price: 0, description: '', finePercentage: 0, interestPercentage: 0 });
setShowAddHandout(false);
setEditingHandoutId(null);
};
const handleEditHandout = (handout: Handout) => {
setNewHandout({
name: handout.name,
price: handout.price,
description: handout.description || '',
finePercentage: handout.finePercentage || 0,
interestPercentage: handout.interestPercentage || 0
});
setEditingHandoutId(handout.id);
setShowAddHandout(true);
};
const handleDeleteHandout = (id: string) => {
showConfirm(
'Excluir Apostila',
'Tem certeza que deseja excluir esta apostila? Isso removerá todos os registros de entrega vinculados.',
() => {
const updatedHandouts = handouts.filter(h => h.id !== id);
const updatedDeliveries = deliveries.filter(d => d.handoutId !== id);
updateData({ handouts: updatedHandouts, handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handouts: updatedHandouts, handoutDeliveries: updatedDeliveries });
}
);
};
const toggleDeliveryStatus = (studentId: string, handoutId: string) => {
const existing = deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
let updatedDeliveries: HandoutDelivery[];
if (existing) {
updatedDeliveries = deliveries.map(d =>
(d.studentId === studentId && d.handoutId === handoutId)
? {
...d,
deliveryStatus: d.deliveryStatus === 'delivered' ? 'pending' : 'delivered',
deliveryDate: d.deliveryStatus === 'delivered' ? undefined : new Date().toISOString()
}
: d
);
} else {
updatedDeliveries = [
...deliveries,
{
id: crypto.randomUUID(),
studentId,
handoutId,
deliveryStatus: 'delivered',
paymentStatus: 'pending',
deliveryDate: new Date().toISOString()
}
];
}
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
};
const togglePaymentStatus = (studentId: string, handoutId: string) => {
const existing = deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
let updatedDeliveries: HandoutDelivery[];
if (existing) {
updatedDeliveries = deliveries.map(d =>
(d.studentId === studentId && d.handoutId === handoutId)
? {
...d,
paymentStatus: d.paymentStatus === 'paid' ? 'pending' : 'paid',
paymentDate: d.paymentStatus === 'paid' ? undefined : new Date().toISOString()
}
: d
);
} else {
updatedDeliveries = [
...deliveries,
{
id: crypto.randomUUID(),
studentId,
handoutId,
deliveryStatus: 'pending',
paymentStatus: 'paid',
paymentDate: new Date().toISOString()
}
];
}
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
};
const getStudentDelivery = (studentId: string, handoutId: string) => {
return deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
};
const filteredClasses = classes.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const filteredStudents = students.filter(s =>
(s.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(s.cpf || '').includes(searchTerm || '')
);
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Gestão de Apostilas</h2>
<p className="text-slate-500 font-medium">Cadastre livros e gerencie entregas e pagamentos.</p>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setShowAddHandout(true)}
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100"
>
<Plus size={20} /> Adicionar Apostila
</button>
</div>
</div>
</header>
{/* Handouts List */}
<section className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Book size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Apostilas Cadastradas</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{handouts.map(handout => (
<div key={handout.id} className="p-4 bg-slate-50 rounded-xl border border-slate-100 flex justify-between items-start group">
<div>
<h4 className="font-bold text-slate-800">{handout.name}</h4>
<p className="text-xs text-slate-500">{handout.description || 'Sem descrição'}</p>
<p className="text-sm font-black text-indigo-600 mt-2">R$ {handout.price.toFixed(2)}</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => handleEditHandout(handout)}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDeleteHandout(handout.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
title="Excluir"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{handouts.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhuma apostila cadastrada.</div>
)}
</div>
</section>
{/* Management Tabs */}
<section className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
<button
onClick={() => { setActiveTab('classes'); setSearchTerm(''); }}
className={`px-6 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'classes' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
POR TURMA
</button>
<button
onClick={() => { setActiveTab('individual'); setSearchTerm(''); }}
className={`px-6 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'individual' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
POR ALUNO
</button>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder={activeTab === 'classes' ? "Buscar turma..." : "Buscar aluno..."}
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{activeTab === 'classes' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredClasses.map(cls => (
<button
key={cls.id}
onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md hover:border-indigo-200 transition-all text-left group"
>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<Users size={24} />
</div>
<ChevronRight size={20} className="text-slate-300 group-hover:text-indigo-400 transition-colors" />
</div>
<h4 className="text-lg font-black text-slate-800 mb-1">{cls.name}</h4>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
{students.filter(s => s.classId === cls.id).length} Alunos
</p>
</button>
))}
{filteredClasses.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">Nenhuma turma encontrada.</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredStudents.map(student => (
<button
key={student.id}
onClick={() => setSelectedStudent(student)}
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md hover:border-indigo-200 transition-all text-left flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-600 font-black">
{student.name.charAt(0)}
</div>
<div>
<h4 className="font-bold text-slate-800 text-sm">{student.name}</h4>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{classes.find(c => c.id === student.classId)?.name || 'Sem Turma'}
</p>
</div>
</button>
))}
{filteredStudents.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">Nenhum aluno encontrado.</div>
)}
</div>
)}
</section>
{/* Add Handout Modal */}
{showAddHandout && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-md rounded-2xl shadow-2xl my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{editingHandoutId ? 'Editar Apostila' : 'Nova Apostila'}</h3>
<p className="text-sm text-slate-500 font-medium">Cadastre e configure os dados do material.</p>
</div>
<button
onClick={closeModal}
className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"
>
<X size={24} />
</button>
</div>
<div className="p-8 space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Nome da Apostila / Livro</label>
<input
type="text"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="Ex: Apostila de Inglês Vol 1"
value={newHandout.name}
onChange={(e) => setNewHandout({...newHandout, name: e.target.value})}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Preço (R$)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0.00"
value={newHandout.price}
onChange={(e) => setNewHandout({...newHandout, price: parseFloat(e.target.value)})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Multa (%)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0"
value={newHandout.finePercentage}
onChange={(e) => setNewHandout({...newHandout, finePercentage: parseFloat(e.target.value)})}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Juros ao Mês (%)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0"
value={newHandout.interestPercentage}
onChange={(e) => setNewHandout({...newHandout, interestPercentage: parseFloat(e.target.value)})}
/>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Descrição (Opcional)</label>
<textarea
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm h-24 resize-none"
placeholder="Detalhes sobre o material..."
value={newHandout.description}
onChange={(e) => setNewHandout({...newHandout, description: e.target.value})}
/>
</div>
<button
onClick={handleAddHandout}
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center justify-center gap-2"
>
<Save size={20} /> Salvar Apostila
</button>
</div>
</div>
</div>
)}
{/* Individual Student Management Modal */}
{selectedStudent && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-2xl rounded-2xl shadow-2xl flex flex-col my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-white shadow-md flex items-center justify-center text-indigo-600 font-black text-2xl border border-indigo-50">
{selectedStudent.name.charAt(0)}
</div>
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{selectedStudent.name}</h3>
<p className="text-sm text-slate-500 font-bold uppercase tracking-widest">
{classes.find(c => c.id === selectedStudent.classId)?.name || 'Sem Turma'}
</p>
</div>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-6">
{handouts.length === 0 ? (
<div className="text-center py-12 text-slate-400 italic">Nenhuma apostila cadastrada.</div>
) : (
<div className="grid grid-cols-1 gap-4">
{handouts.map(handout => {
const delivery = getStudentDelivery(selectedStudent.id, handout.id);
return (
<div key={handout.id} className="bg-slate-50 p-4 rounded-xl border border-slate-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h5 className="font-bold text-slate-800">{handout.name}</h5>
<p className="text-xs font-black text-indigo-600">R$ {handout.price.toFixed(2)}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => toggleDeliveryStatus(selectedStudent.id, handout.id)}
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all flex items-center gap-2 ${
delivery?.deliveryStatus === 'delivered'
? 'bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-white text-slate-400 border border-slate-200 hover:bg-slate-50'
}`}
>
<Package size={14} />
{delivery?.deliveryStatus === 'delivered' ? 'Entregue' : 'Entrega'}
</button>
<button
onClick={() => togglePaymentStatus(selectedStudent.id, handout.id)}
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all flex items-center gap-2 ${
delivery?.paymentStatus === 'paid'
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'bg-white text-slate-400 border border-slate-200 hover:bg-slate-50'
}`}
>
<DollarSign size={14} />
{delivery?.paymentStatus === 'paid' ? 'Pago' : 'Pagamento'}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
{/* Class Management Modal */}
{selectedClass && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-5xl h-[85vh] rounded-2xl shadow-2xl flex flex-col my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{selectedClass.name}</h3>
<p className="text-sm text-slate-500 font-bold uppercase tracking-widest">Controle de Entregas por Turma</p>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
{handouts.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-4">
<AlertCircle size={48} className="opacity-20" />
<p className="font-medium">Nenhuma apostila cadastrada para gerenciar.</p>
</div>
) : (
<div className="space-y-8">
{students.filter(s => s.classId === selectedClass.id).map(student => (
<div key={student.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center gap-3 border-b border-slate-200 pb-4">
<div className="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-black">
{student.name.charAt(0)}
</div>
<h4 className="font-black text-slate-800">{student.name}</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{handouts.map(handout => {
const delivery = getStudentDelivery(student.id, handout.id);
return (
<div key={handout.id} className="bg-white p-4 rounded-xl border border-slate-200 space-y-4 shadow-sm">
<div className="flex justify-between items-start">
<h5 className="font-bold text-slate-700 text-sm">{handout.name}</h5>
<span className="text-[10px] font-black text-indigo-600 bg-indigo-50 px-2 py-1 rounded">R$ {handout.price.toFixed(2)}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => toggleDeliveryStatus(student.id, handout.id)}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
delivery?.deliveryStatus === 'delivered'
? 'bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-slate-100 text-slate-400 border border-slate-200 hover:bg-slate-200'
}`}
>
<Package size={14} />
{delivery?.deliveryStatus === 'delivered' ? 'Entregue' : 'Entrega'}
</button>
<button
onClick={() => togglePaymentStatus(student.id, handout.id)}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
delivery?.paymentStatus === 'paid'
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'bg-slate-100 text-slate-400 border border-slate-200 hover:bg-slate-200'
}`}
>
<DollarSign size={14} />
{delivery?.paymentStatus === 'paid' ? 'Pago' : 'Pagamento'}
</button>
</div>
{(delivery?.deliveryDate || delivery?.paymentDate) && (
<div className="pt-2 border-t border-slate-100 space-y-1">
{delivery.deliveryDate && (
<p className="text-[9px] text-slate-400 flex items-center gap-1">
<CheckCircle size={10} className="text-emerald-500" />
Entrega: {new Date(delivery.deliveryDate).toLocaleDateString()}
</p>
)}
{delivery.paymentDate && (
<p className="text-[9px] text-slate-400 flex items-center gap-1">
<CheckCircle size={10} className="text-amber-500" />
Pagamento: {new Date(delivery.paymentDate).toLocaleDateString()}
</p>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
{students.filter(s => s.classId === selectedClass.id).length === 0 && (
<div className="text-center py-12 text-slate-400 italic">Nenhum aluno nesta turma.</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default Handouts;

View File

@ -0,0 +1,821 @@
import React, { useState } from 'react';
import { SchoolData, Class, Lesson, Notification } from '../types';
import { useDialog } from '../DialogContext';
import { Calendar, Plus, X, AlertCircle, RefreshCw, Send, CheckCircle, Search, Clock, Trash2 } from 'lucide-react';
import { dbService } from '../services/dbService';
interface LessonScheduleProps {
classObj: Class;
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
onClose: () => void;
}
const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateData, onClose }) => {
const { showAlert, showConfirm } = useDialog();
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showLessonDetail, setShowLessonDetail] = useState<Lesson | null>(null);
const [isClosing, setIsClosing] = useState(false);
// Form states for generation
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [dayOfWeek, setDayOfWeek] = useState('1');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [extraCount, setExtraCount] = useState<number | ''>('');
React.useEffect(() => {
if (extraCount && startDate && dayOfWeek) {
let current = new Date(startDate + 'T12:00:00Z');
const day = parseInt(dayOfWeek, 10);
while (current.getUTCDay() !== day) {
current.setUTCDate(current.getUTCDate() + 1);
}
current.setUTCDate(current.getUTCDate() + (7 * (Number(extraCount) - 1)));
setEndDate(current.toISOString().split('T')[0]);
}
}, [extraCount, startDate, dayOfWeek]);
// Form states for cancellation
const [cancelReason, setCancelReason] = useState('');
const [wantReplacement, setWantReplacement] = useState(false);
const [replacementDate, setReplacementDate] = useState('');
const [replacementStartTime, setReplacementStartTime] = useState('');
const [replacementEndTime, setReplacementEndTime] = useState('');
const checkCollision = (date: string, start: string, end: string, ignoreLessonId?: string) => {
return (data.lessons || []).find(l => {
// Ignore if it's the lesson being replaced (if any) or if it's cancelled
if (l.id === ignoreLessonId || l.status === 'cancelled') return false;
if (l.date !== date) return false;
if (!l.startTime || !l.endTime) return false;
// Só dá conflito se for na mesma TURMA ou com o mesmo PROFESSOR
const isSameClass = l.classId === classObj.id;
const otherClass = data.classes.find(c => c.id === l.classId);
const isSameTeacher = otherClass && classObj.teacher && otherClass.teacher === classObj.teacher;
if (!isSameClass && !isSameTeacher) return false;
// Regra: NovoInicio < HorarioFimExistente AND NovoFim > HorarioInicioExistente
return (start < l.endTime) && (end > l.startTime);
});
};
const classLessons = (data.lessons || [])
.filter(l => l.classId === classObj.id)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const handleGenerateLessons = () => {
if (!startDate || !endDate || !dayOfWeek || !startTime || !endTime) {
showAlert('Atenção', 'Preencha todos os campos, incluindo horários de início e término.', 'warning');
return;
}
if (startTime >= endTime) {
showAlert('Atenção', 'O horário de término deve ser maior que o de início.', 'warning');
return;
}
// Bloqueio de retroatividade removido conforme solicitado.
const start = new Date(startDate);
const end = new Date(endDate);
const day = parseInt(dayOfWeek, 10);
const newLessons: Lesson[] = [];
const ignoredDates: string[] = [];
// Increment date until finding the exact day
let current = new Date(start);
while (current.getUTCDay() !== day) {
current.setUTCDate(current.getUTCDate() + 1);
}
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
// Validação de Choque de Horários
if (checkCollision(dateStr, startTime, endTime)) {
ignoredDates.push(new Date(dateStr + 'T12:00:00Z').toLocaleDateString('pt-BR'));
} else {
newLessons.push({
id: crypto.randomUUID(),
classId: classObj.id,
date: dateStr,
startTime,
endTime,
status: 'scheduled',
type: 'extra'
});
}
current.setUTCDate(current.getUTCDate() + 7); // advance one week
}
if (newLessons.length === 0 && ignoredDates.length === 0) {
showAlert('Atenção', 'Nenhuma data encontrada nesse período para o dia da semana selecionado.', 'warning');
return;
}
if (newLessons.length === 0 && ignoredDates.length > 0) {
showAlert('⚠️ Choque de Horários!', `Nenhuma aula gerada. Todas as datas pretendidas deram choque com horários existentes: ${ignoredDates.join(', ')}`, 'warning');
return;
}
const updatedLessons = [...(data.lessons || []), ...newLessons];
// Notificar alunos sobre novas aulas extras geradas
const datesList = newLessons.map(l => new Date(l.date + 'T12:00:00Z').toLocaleDateString('pt-BR')).join(', ');
const notifMsg = `Novas aulas extras foram agendadas para os dias: ${datesList} (${startTime} às ${endTime}).`;
const waMsg = `📅 *Novas Aulas Extras Agendadas!*\n\nOlá, {nome}!\nInformamos que foram agendadas novas aulas extras para a turma *${classObj.name}*.\n\n*Datas:* ${datesList}\n*Horário:* ${startTime} às ${endTime}\n\nAguardamos você!`;
const newNotifs = notifyLessonAction('Aulas Extras Agendadas', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ lessons: updatedLessons, notifications: updatedNotifications });
dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });
setShowGenerateModal(false);
if (ignoredDates.length > 0) {
showAlert('Aviso de Agendamento Parcial', `Aulas geradas, porém os dias ${ignoredDates.join(', ')} foram ignorados devido a choque de horário no mesmo intervalo (⚠️ Choque de Horários!).`, 'warning');
} else {
showAlert('Sucesso', `${newLessons.length} aulas extras geradas e alunos notificados!`, 'success');
}
};
const notifyLessonAction = (title: string, notificationMessage: string, waMessage: string) => {
const students = data.students.filter(s => s.status === 'active' && s.classId === classObj.id);
// Notificações Portal do Aluno
const newNotifs: Notification[] = students.map(s => ({
id: crypto.randomUUID(),
studentId: s.id,
title,
message: notificationMessage,
read: false,
createdAt: new Date().toISOString()
}));
// Mensagens WhatsApp
try {
const payloadAlunos = students.flatMap(student => {
const birthDateStr = student.birthDate || '';
let age = 18;
if (birthDateStr && birthDateStr.includes('-')) {
const [year, month, day] = birthDateStr.split('-').map(Number);
const birthDate = new Date(year, month - 1, day);
const today = new Date();
age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;
}
const isMinor = age < 18;
const targets = [];
if (isMinor) {
// 1. Envia para o Responsável
if (student.guardianPhone?.trim()) {
targets.push({
nome: student.guardianName?.trim() || 'Responsável',
telefone: student.guardianPhone.trim(),
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
// 2. Envia para o Aluno (se ele tiver celular próprio)
if (student.phone?.trim()) {
targets.push({
nome: student.name || 'Aluno',
telefone: student.phone.trim(),
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
} else {
// 3. Regra Maior de Idade (inalterada) - O foco é o próprio aluno
targets.push({
nome: student.name || 'Aluno',
telefone: student.phone?.trim() || student.guardianPhone?.trim() || '',
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
// Remove possíveis contatos sem telefone para não bugar a API
return targets.filter(t => t.telefone);
});
fetch('/api/enviar-massa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alunos: payloadAlunos, mensagem: waMessage })
}).catch(e => console.warn(e));
} catch (e) {
console.warn("Falha silenciosa api enviar-massa", e);
}
return newNotifs;
};
const handleCancelLesson = async (lesson: Lesson) => {
if (!cancelReason) {
showAlert('Atenção', 'Informe o motivo do cancelamento.', 'warning');
return;
}
if (wantReplacement) {
if (!replacementDate || !replacementStartTime || !replacementEndTime) {
showAlert('Atenção', 'Informe a data e os horários da reposição.', 'warning');
return;
}
if (replacementStartTime >= replacementEndTime) {
showAlert('Atenção', 'O horário de término da reposição deve ser maior que o de início.', 'warning');
return;
}
if (checkCollision(replacementDate, replacementStartTime, replacementEndTime, lesson.id)) {
showAlert('⚠️ Choque de Horários!', 'Já existe uma aula marcada para este dia neste intervalo de tempo. Por favor, escolha outro horário.', 'warning');
return;
}
}
setIsClosing(true);
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
l.id === lesson.id ? { ...l, status: 'cancelled', cancelReason } : l
);
let replacementStr = '';
if (wantReplacement && replacementDate) {
updatedLessons.push({
id: crypto.randomUUID(),
classId: classObj.id,
date: replacementDate,
startTime: replacementStartTime,
endTime: replacementEndTime,
status: 'scheduled',
type: 'reposicao',
originalLessonId: lesson.id
});
replacementStr = `\n✅ *Reposição agendada:* ${new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR')}`;
}
const lessonDateStr = new Date(lesson.date + 'T12:00:00Z').toLocaleDateString('pt-BR');
const notifMsg = `A aula do dia ${lessonDateStr} foi cancelada. Motivo: ${cancelReason}. ${wantReplacement ? `Uma reposição foi agendada para o dia ${new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR')}.` : ''}`;
const waMsg = `🚨 *Aviso Importante: Aula Cancelada*\n\nOlá, {nome}!\nInformamos que a aula da turma *${classObj.name}* do dia *${lessonDateStr}* foi cancelada.\n\n*Motivo:* ${cancelReason}${replacementStr}\n\nAgradecemos a compreensão.`;
const newNotifs = notifyLessonAction('Aula Cancelada', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ lessons: updatedLessons, notifications: updatedNotifications });
await dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setCancelReason('');
setWantReplacement(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
showAlert('Sucesso', 'Aula cancelada e alunos notificados.', 'success');
}, 400);
};
const handleUncancelLesson = async (lesson: Lesson) => {
setIsClosing(true);
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
l.id === lesson.id ? { ...l, status: 'scheduled', cancelReason: undefined } : l
);
updateData({ lessons: updatedLessons });
await dbService.saveData({ ...data, lessons: updatedLessons });
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
showAlert('Sucesso', 'Aula reativada com sucesso.', 'success');
}, 400);
};
const handleRescheduleLesson = async (lesson: Lesson) => {
if (!replacementDate || !replacementStartTime || !replacementEndTime) {
showAlert('Atenção', 'Informe nova data e horários.', 'warning');
return;
}
if (replacementStartTime >= replacementEndTime) {
showAlert('Atenção', 'Horário de término deve ser maior que o de início.', 'warning');
return;
}
if (checkCollision(replacementDate, replacementStartTime, replacementEndTime, lesson.id)) {
showAlert('⚠️ Choque de Horários!', 'Já existe uma aula marcada para este intervalo de tempo.', 'warning');
return;
}
if (!cancelReason) {
showAlert('Atenção', 'Informe o motivo do reagendamento.', 'warning');
return;
}
setIsClosing(true);
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
l.id === lesson.id ? { ...l, date: replacementDate, startTime: replacementStartTime, endTime: replacementEndTime, status: 'rescheduled', type: l.type, cancelReason: undefined } : l
);
const oldDateStr = new Date(lesson.date + 'T12:00:00Z').toLocaleDateString('pt-BR');
const newDateStr = new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR');
const notifMsg = `A aula do dia ${oldDateStr} foi reagendada para ${newDateStr} (${replacementStartTime} às ${replacementEndTime}). Motivo: ${cancelReason}.`;
const waMsg = `📅 *Aviso de Reagendamento*\n\nOlá, {nome}!\nInformamos que a aula da turma *${classObj.name}* originalmente do dia *${oldDateStr}* foi reagendada.\n\n*Nova Data:* ${newDateStr}\n*Novo Horário:* ${replacementStartTime} às ${replacementEndTime}\n*Motivo:* ${cancelReason}\n\nAgradecemos a compreensão!`;
const newNotifs = notifyLessonAction('Aula Reagendada', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ lessons: updatedLessons, notifications: updatedNotifications });
await dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
showAlert('Sucesso', 'Aula reagendada com sucesso.', 'success');
}, 400);
};
const handleCancelAllFuture = () => {
showConfirm('Cancelar Cronograma', 'Deseja realmente cancelar TODAS as aulas futuras não realizadas? Não haverá reposição e a ação atualizará todas para Cancelada.', async () => {
const today = new Date().toISOString().split('T')[0];
const updatedLessons = (data.lessons || []).map(l => {
if (l.classId === classObj.id && l.status === 'scheduled' && l.date >= today) {
return { ...l, status: 'cancelled', cancelReason: 'Cancelamento Geral de Cronograma' };
}
return l;
});
updateData({ lessons: updatedLessons as Lesson[] });
await dbService.saveData({ ...data, lessons: updatedLessons as Lesson[] });
showAlert('Sucesso', 'Cronograma futuro cancelado.', 'success');
});
};
const handleUncancelAllFuture = () => {
showConfirm('Reativar Cronograma', 'Deseja realmente reativar TODAS as aulas futuras que estavam canceladas?', async () => {
const today = new Date().toISOString().split('T')[0];
const updatedLessons = (data.lessons || []).map(l => {
if (l.classId === classObj.id && l.status === 'cancelled' && l.date >= today) {
return { ...l, status: 'scheduled', cancelReason: undefined };
}
return l;
});
updateData({ lessons: updatedLessons as Lesson[] });
await dbService.saveData({ ...data, lessons: updatedLessons as Lesson[] });
showAlert('Sucesso', 'Cronograma futuro reativado com sucesso.', 'success');
});
};
const handleDeleteAllSchedule = () => {
showConfirm('Excluir Cronograma Completo', '⚠️ Tem certeza? Isso removerá TODAS as aulas desta turma permanentemente (agendadas, canceladas e reposições). Esta ação NÃO pode ser desfeita.', async () => {
const updatedLessons = (data.lessons || []).filter(l => l.classId !== classObj.id);
updateData({ lessons: updatedLessons });
await dbService.saveData({ ...data, lessons: updatedLessons });
showAlert('Sucesso', 'Cronograma completo excluído.', 'success');
});
};
const closeLessonDetail = () => {
setIsClosing(true);
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setCancelReason('');
setWantReplacement(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
}, 400);
};
return (
<div className="fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto animate-in fade-in duration-300">
<div className="bg-slate-50 rounded-2xl w-full max-w-4xl shadow-2xl relative overflow-hidden flex flex-col max-h-[90vh] animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-20"></div>
{/* Header */}
<div className="p-4 md:p-6 border-b border-slate-200 bg-white z-10 sticky top-0">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight flex items-center gap-2">
<Calendar className="text-indigo-600" /> Cronograma de Aulas
</h3>
<p className="text-sm text-slate-500 font-medium">Turma: {classObj.name}</p>
</div>
<button onClick={onClose} className="p-2.5 bg-slate-100 text-slate-500 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all flex-shrink-0">
<X size={20} />
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleDeleteAllSchedule}
className="px-3 py-1.5 bg-red-500 text-white font-bold rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5 shadow-sm text-xs"
title="Exclui todo o cronograma permanentemente"
>
<Trash2 size={14} /> Excluir Tudo
</button>
<button
onClick={handleCancelAllFuture}
className="px-3 py-1.5 bg-red-100 text-red-700 font-bold rounded-lg hover:bg-red-200 transition-colors flex items-center gap-1.5 text-xs"
title="Cancela todas as próximas aulas da turma"
>
<AlertCircle size={14} /> Cancelar Todas
</button>
<button
onClick={handleUncancelAllFuture}
className="px-3 py-1.5 bg-emerald-100 text-emerald-700 font-bold rounded-lg hover:bg-emerald-200 transition-colors flex items-center gap-1.5 text-xs"
title="Reativa todas as próximas aulas canceladas"
>
<RefreshCw size={14} /> Reativar Todas
</button>
<button
onClick={() => setShowGenerateModal(true)}
className="px-3 py-1.5 bg-indigo-100 text-indigo-700 font-bold rounded-lg hover:bg-indigo-200 transition-colors flex items-center gap-1.5 text-xs"
>
<Plus size={14} /> Adicionar Extra
</button>
</div>
</div>
{/* Lesson Stats Bar */}
{classLessons.length > 0 && (() => {
const now = new Date();
const totalLessons = classLessons.length;
const completedLessons = classLessons.filter(l => {
if (l.status === 'cancelled') return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (!l.endTime) return lDate < now;
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(lDate);
lEnd.setUTCHours(eh, em, 0, 0);
return now > lEnd;
}).length;
const cancelledLessons = classLessons.filter(l => l.status === 'cancelled').length;
const remainingLessons = totalLessons - completedLessons - cancelledLessons;
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return (
<div className="px-6 py-3 bg-white border-b border-slate-100 flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-2 text-[11px] font-black flex-1">
<span className="px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-lg flex items-center gap-1">
<Calendar size={12} /> {totalLessons} Total
</span>
<span className="px-2.5 py-1 bg-emerald-50 text-emerald-700 rounded-lg flex items-center gap-1">
<CheckCircle size={12} /> {completedLessons} Concluídas
</span>
<span className="px-2.5 py-1 bg-amber-50 text-amber-700 rounded-lg flex items-center gap-1">
<Clock size={12} /> {remainingLessons} Restantes
</span>
{cancelledLessons > 0 && (
<span className="px-2.5 py-1 bg-red-50 text-red-600 rounded-lg flex items-center gap-1">
<AlertCircle size={12} /> {cancelledLessons} Canceladas
</span>
)}
</div>
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1 bg-slate-100 h-2 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] font-black text-slate-500 whitespace-nowrap">{progressPercent}%</span>
</div>
</div>
);
})()}
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
{classLessons.length === 0 ? (
<div className="py-20 text-center text-slate-400">
<Calendar size={64} className="mx-auto mb-4 opacity-20" />
<p className="font-bold text-xl">Nenhuma aula gerada ainda.</p>
<p className="text-sm mt-2">Clique em "Gerar Aulas do Ano" para preencher o cronograma.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{classLessons.map(lesson => {
const dateObj = new Date(lesson.date);
const displayDate = new Date(dateObj.getTime() + dateObj.getTimezoneOffset() * 60000);
const now = new Date();
const [startH, startM] = (lesson.startTime || "00:00").split(':').map(Number);
const [endH, endM] = (lesson.endTime || "23:59").split(':').map(Number);
const lessonStart = new Date(displayDate);
lessonStart.setHours(startH, startM, 0);
const lessonEnd = new Date(displayDate);
lessonEnd.setHours(endH, endM, 0);
const isCancelled = lesson.status === 'cancelled';
const isRescheduled = lesson.status === 'rescheduled';
const isCompletedStatus = lesson.status === 'completed' || (now > lessonEnd && !isCancelled);
const isInProgress = !isCancelled && now >= lessonStart && now <= lessonEnd;
const isReposicao = lesson.type === 'reposicao';
const isExtra = lesson.type === 'extra';
const isPast = lessonEnd < now && !isInProgress;
return (
<div
key={lesson.id}
onClick={() => setShowLessonDetail(lesson)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all hover:scale-105 ${
isCancelled
? 'bg-red-50 border-red-200 opacity-80'
: isInProgress
? 'bg-indigo-50 border-indigo-400 shadow-indigo-100 shadow-lg'
: isPast || isCompletedStatus
? 'bg-slate-50 border-slate-200 opacity-60 grayscale-[0.3]'
: isRescheduled
? 'bg-orange-50 border-orange-300 shadow-sm'
: isExtra
? 'bg-purple-50 border-purple-200'
: isReposicao
? 'bg-emerald-100 border-emerald-300'
: 'bg-emerald-50 border-emerald-100 hover:border-emerald-300'
}`}
>
<div className="text-center">
<p className={`text-2xl font-black mb-0 ${isCancelled ? 'text-red-600 line-through' : 'text-slate-800'}`}>
{displayDate.getDate().toString().padStart(2, '0')}
</p>
<p className={`text-[10px] uppercase font-bold tracking-widest ${isCancelled ? 'text-red-400' : 'text-slate-400'}`}>
{displayDate.toLocaleString('pt-BR', { month: 'short' })} {displayDate.getFullYear()}
</p>
{lesson.startTime && lesson.endTime && (
<p className={`text-[9px] font-black tracking-wider mt-1 ${isCancelled ? 'text-red-400' : isInProgress ? 'text-indigo-600' : 'text-indigo-500'}`}>
{lesson.startTime} - {lesson.endTime}
</p>
)}
<div className="flex flex-wrap justify-center gap-1 mt-2">
{isCancelled && (
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-[9px] font-black uppercase rounded-full">
Cancelada
</span>
)}
{isInProgress && (
<span className="px-2 py-0.5 bg-blue-600 text-white text-[9px] font-black uppercase rounded-full animate-pulse flex items-center gap-1">
<Clock size={8} /> Em andamento
</span>
)}
{isCompletedStatus && !isCancelled && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-600 text-[9px] font-black uppercase rounded-full">
Concluída
</span>
)}
{isRescheduled && !isCancelled && !isReposicao && !isInProgress && !isCompletedStatus && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 text-[9px] font-black uppercase rounded-full">
Reagendada
</span>
)}
{isExtra && !isCancelled && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-[9px] font-black uppercase rounded-full">
Aula Extra
</span>
)}
{isReposicao && !isCancelled && (
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 text-[9px] font-black uppercase rounded-full">
Reposição
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Generate Lessons Modal */}
{showGenerateModal && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-2xl w-full max-w-sm p-6 shadow-2xl animate-slide-up">
<h3 className="text-xl font-black text-slate-800 mb-4">Adicionar Aula Extra</h3>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Quantidade de Aulas Adicionais</label>
<input type="number" min="1" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={extraCount} onChange={e => setExtraCount(parseInt(e.target.value) || '')} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Data Início</label>
<input type="date" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Data Fim (Automática)</label>
<input type="date" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Dia da Semana</label>
<select className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={dayOfWeek} onChange={e => setDayOfWeek(e.target.value)}>
<option value="0">Domingo</option>
<option value="1">Segunda-feira</option>
<option value="2">Terça-feira</option>
<option value="3">Quarta-feira</option>
<option value="4">Quinta-feira</option>
<option value="5">Sexta-feira</option>
<option value="6">Sábado</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Horário Início</label>
<input type="time" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={startTime} onChange={e => setStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Horário Fim</label>
<input type="time" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={endTime} onChange={e => setEndTime(e.target.value)} />
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-100">
<button onClick={() => setShowGenerateModal(false)} className="flex-1 py-3 text-slate-500 font-bold hover:bg-slate-50 rounded-lg transition-colors">Cancelar</button>
<button onClick={handleGenerateLessons} className="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-lg hover:bg-indigo-700 transition-colors">Adicionar</button>
</div>
</div>
</div>
</div>
)}
{/* Lesson Details & Cancellation Modal */}
{showLessonDetail && (
<div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className={`p-6 border-b flex justify-between items-center ${showLessonDetail.status === 'cancelled' ? 'bg-red-50 border-red-100' : 'bg-slate-50 border-slate-100'}`}>
<h3 className="text-xl font-black text-slate-800">Detalhes da Aula</h3>
<button onClick={closeLessonDetail} className="text-slate-400 hover:text-red-500 transition-colors"><X size={20}/></button>
</div>
<div className="p-6">
<p className="text-sm font-bold text-slate-500 mb-1">Data Agendada</p>
<p className="text-2xl font-black text-slate-800">
{new Date(showLessonDetail.date + 'T12:00:00Z').toLocaleDateString('pt-BR')}
</p>
{showLessonDetail.startTime && showLessonDetail.endTime && (
<p className="text-indigo-600 font-bold mb-6 mt-1 flex items-center gap-1.5 text-sm">
<Clock size={16} /> {showLessonDetail.startTime} às {showLessonDetail.endTime}
</p>
)}
{!showLessonDetail.startTime && <div className="mb-6"></div>}
{showLessonDetail.status === 'cancelled' ? (
<div className="space-y-4">
<div className="p-4 bg-red-50 rounded-xl border border-red-100 text-red-800">
<div className="flex items-center gap-2 mb-2">
<AlertCircle size={18} /> <span className="font-bold">Aula Cancelada</span>
</div>
<p className="text-sm"><strong>Motivo:</strong> {showLessonDetail.cancelReason}</p>
</div>
{!wantReplacement ? (
<div className="flex gap-2">
<button
onClick={() => handleUncancelLesson(showLessonDetail)}
className="flex-1 py-4 bg-emerald-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-emerald-600 transition-colors shadow-sm"
>
<RefreshCw size={18} /> Reativar
</button>
<button
onClick={() => setWantReplacement(true)}
className="flex-1 py-4 bg-indigo-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-indigo-600 transition-colors shadow-sm"
>
<Calendar size={18} /> Reagendar
</button>
</div>
) : (
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 mt-2 animate-in fade-in slide-in-from-top-2 space-y-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-bold text-slate-700">Reagendar Aula Cancelada</label>
<button onClick={() => setWantReplacement(false)} className="text-slate-400 hover:text-red-500"><X size={16}/></button>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Nova Data</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementDate} onChange={e => setReplacementDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Início</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementStartTime} onChange={e => setReplacementStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Fim</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementEndTime} onChange={e => setReplacementEndTime(e.target.value)} />
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Motivo do Reagendamento</label>
<textarea
className="w-full p-3 bg-white border border-indigo-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm placeholder-slate-300"
placeholder="Ex: Confirmação de disponibilidade..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
</div>
<button
onClick={() => handleRescheduleLesson(showLessonDetail)}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-indigo-700 transition-colors"
>
Salvar e Notificar Alunos
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" className="w-4 h-4 text-indigo-600 rounded"
checked={wantReplacement} onChange={e => {
setWantReplacement(e.target.checked);
if (e.target.checked) setCancelReason('');
}} />
<span className="text-sm font-bold text-slate-700">Reagendar esta aula (manter existente, alterar dia)</span>
</label>
<p className="text-[10px] text-slate-500 mt-1 mb-2 leading-tight">Marque se deseja apenas trocar a data/horário. Os alunos serão notificados do reagendamento.</p>
{wantReplacement && (
<div className="mt-2 animate-in fade-in slide-in-from-top-2 space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Nova Data</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementDate} onChange={e => setReplacementDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Início</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementStartTime} onChange={e => setReplacementStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Fim</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementEndTime} onChange={e => setReplacementEndTime(e.target.value)} />
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Motivo da Alteração</label>
<textarea
className="w-full p-3 bg-white border border-indigo-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm placeholder-slate-300"
placeholder="Ex: Mudança a pedido da turma..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
</div>
<button
onClick={() => handleRescheduleLesson(showLessonDetail)}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-indigo-700 transition-colors"
>
Salvar e Notificar Alunos
</button>
</div>
)}
</div>
{!wantReplacement && (
<div className="p-4 bg-red-50/50 rounded-xl border border-red-100 mt-4 animate-in fade-in">
<label className="block text-[10px] font-bold text-red-500 uppercase tracking-widest mb-1">Cancelar Aula - Informe o Motivo</label>
<textarea
className="w-full p-3 bg-white border border-red-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 text-sm placeholder-slate-300"
placeholder="Ex: Doença do professor..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
<button
onClick={() => handleCancelLesson(showLessonDetail)}
className="w-full mt-4 py-4 bg-red-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-red-600 transition-colors shadow-lg shadow-red-200"
>
<AlertCircle size={20} /> Cancelar e Notificar
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default LessonSchedule;

View File

@ -0,0 +1,522 @@
import React, { useState } from 'react';
import { SchoolData } from '../types';
import { useDialog } from '../DialogContext';
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X } from 'lucide-react';
interface MessagesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const defaultTemplates = {
boletoGerado: "Olá {nome}, sua cobrança referente a {descricao} no valor de R$ {valor} foi gerada. Vencimento: {vencimento}.",
pagamentoConfirmado: "Olá {nome}, confirmamos o pagamento de R$ {valor} referente a {descricao}. Muito obrigado!",
boletoVencido: "Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:",
cobrancaCancelada: "Olá {nome}, a cobrança referente a {descricao} foi cancelada.",
cobrancaAtualizada: "Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:",
felizAniversario: "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈",
automationRules: {
sendOnDueDate: true,
sendDaysAfter: '1',
repeatEveryDays: '3'
}
};
const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const defaultVars = data.messageTemplates || defaultTemplates;
const initRules = defaultVars.automationRules || defaultTemplates.automationRules;
const [templates, setTemplates] = useState({
...defaultTemplates,
...defaultVars,
automationRules: {
...defaultTemplates.automationRules,
...initRules
}
});
const [isSending, setIsSending] = useState(false);
// Estados WhatsApp em Massa
const [targetType, setTargetType] = useState('todos');
const [targetId, setTargetId] = useState('');
const [messageText, setMessageText] = useState('');
const [isSendingMass, setIsSendingMass] = useState(false);
const [isSendingBdays, setIsSendingBdays] = useState(false);
// Modal de Edição de Modelo
const [editingTemplate, setEditingTemplate] = useState<{
key: keyof typeof defaultTemplates | 'felizAniversario',
label: string,
desc: string,
color: string,
icon: any,
vars: string[]
} | null>(null);
const normalizeLineBreaks = (text: string) => text.replace(/\r\n/g, '\n');
const birthdayStudents = (data.students || []).filter(s => {
if (!s.birthDate || s.status !== 'active') return false;
const bdayParts = s.birthDate.split('-');
const bdayDay = parseInt(bdayParts[2]);
const bdayMonth = parseInt(bdayParts[1]);
const today = new Date();
return bdayDay === today.getDate() && bdayMonth === (today.getMonth() + 1);
});
const handleSendBirthdays = async () => {
if (birthdayStudents.length === 0) return;
showConfirm(
'Enviar Felicitações',
`Deseja enviar a mensagem de aniversário para os ${birthdayStudents.length} alunos que fazem aniversário hoje?`,
async () => {
setIsSendingBdays(true);
try {
const payloadAlunos = birthdayStudents.map(s => {
const nome = s.name.split(' ')[0];
const telefone = s.phone || s.guardianPhone;
return { nome, telefone };
}).filter(a => a.telefone);
if (payloadAlunos.length === 0) {
showAlert('Aviso', 'Nenhum dos aniversariantes possui telefone cadastrado.', 'warning');
return;
}
const msgTemplate = normalizeLineBreaks(templates.felizAniversario).replace(/{escola}/g, data.profile.name);
const resp = await fetch('/api/enviar-massa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alunos: payloadAlunos, mensagem: msgTemplate })
});
if (resp.ok) {
showAlert('Sucesso', 'O disparo das mensagens de aniversário foi iniciado!', 'success');
} else {
const resData = await resp.json();
showAlert('Erro', resData.error || 'Erro ao iniciar disparo.', 'error');
}
} catch (e) {
showAlert('Erro', 'Erro de conexão.', 'error');
} finally {
setIsSendingBdays(false);
}
}
);
};
const handleSave = () => {
const normalizedTemplates = {
...templates,
boletoGerado: normalizeLineBreaks(templates.boletoGerado),
pagamentoConfirmado: normalizeLineBreaks(templates.pagamentoConfirmado),
boletoVencido: normalizeLineBreaks(templates.boletoVencido),
cobrancaCancelada: normalizeLineBreaks(templates.cobrancaCancelada),
cobrancaAtualizada: normalizeLineBreaks(templates.cobrancaAtualizada),
felizAniversario: normalizeLineBreaks(templates.felizAniversario)
};
updateData({ messageTemplates: normalizedTemplates });
showAlert('Sucesso', 'Configurações de mensagens salvas com sucesso!', 'success');
};
const handleDispararCobrancas = async () => {
showConfirm(
'Disparar Cobranças',
'Tem certeza que deseja processar e enviar as mensagens para TODOS os alunos com pagamentos atrasados agora?',
async () => {
setIsSending(true);
try {
const resp = await fetch('/api/disparar_cobrancas', { method: 'POST' });
const resData = await resp.json();
if (resp.ok) {
showAlert('Sucesso', resData.message || 'Cobranças processadas e disparadas com sucesso!', 'success');
} else {
showAlert('Erro', resData.error || 'Erro ao disparar cobranças', 'error');
}
} catch (e: any) {
showAlert('Erro', 'Erro de conexão ao disparar cobranças.', 'error');
} finally {
setIsSending(false);
}
}
);
};
const handleMassSend = async () => {
if (!messageText.trim()) {
return showAlert('Aviso', 'Digite uma mensagem para enviar.', 'warning');
}
let targetStudents = [];
if (targetType === 'todos') {
targetStudents = data.students || [];
} else if (targetType === 'turma') {
if (!targetId) return showAlert('Aviso', 'Selecione uma turma.', 'warning');
targetStudents = (data.students || []).filter(s => s.classId === targetId);
} else if (targetType === 'aluno') {
if (!targetId) return showAlert('Aviso', 'Selecione um aluno.', 'warning');
targetStudents = (data.students || []).filter(s => s.id === targetId);
}
const validStudents = targetStudents.filter(a => a.phone || a.guardianPhone);
if (validStudents.length === 0) {
return showAlert('Erro', 'Nenhum aluno com telefone cadastrado foi selecionado.', 'error');
}
const payloadAlunos = validStudents.map(a => {
let nome = a.name;
let telefone = a.phone;
if (a.birthDate) {
const birthDate = new Date(a.birthDate);
const age = Math.abs(new Date(Date.now() - birthDate.getTime()).getUTCFullYear() - 1970);
if (age < 18) {
nome = a.guardianName || a.name;
telefone = a.guardianPhone || a.phone;
}
}
return { nome, telefone, matricula: a.enrollmentNumber || '—' };
});
setIsSendingMass(true);
try {
const resp = await fetch('/api/enviar-massa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alunos: payloadAlunos, mensagem: normalizeLineBreaks(messageText) })
});
const resData = await resp.json();
if (resp.ok) {
setMessageText('');
setTargetId('');
showAlert('Sucesso', 'Disparo iniciado no servidor! Você já pode fechar esta tela ou continuar usando o sistema.', 'success');
} else {
showAlert('Erro', resData.error || 'Erro ao iniciar disparo.', 'error');
}
} catch (e) {
showAlert('Erro', 'Erro de conexão.', 'error');
} finally {
setIsSendingMass(false);
}
};
const templateCards = [
{ key: 'boletoGerado', label: 'Boleto Gerado / Novo Carnê', desc: 'Enviado assim que a cobrança é criada no sistema.', color: 'blue', icon: FileText, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'pagamentoConfirmado', label: 'Pagamento Confirmado', desc: 'Enviado quando o sistema (Asaas) compensa o pagamento.', color: 'emerald', icon: CheckCircle, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{escola}'] },
{ key: 'boletoVencido', label: 'Boleto Vencido', desc: 'Enviado conforme automação ou disparo manual de atrasados.', color: 'red', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'cobrancaCancelada', label: 'Cobrança Cancelada', desc: 'Enviado quando o boleto for cancelado no sistema.', color: 'slate', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{escola}'] },
{ key: 'cobrancaAtualizada', label: 'Cobrança Atualizada', desc: 'Enviado quando houver edição/atualização da cobrança.', color: 'amber', icon: Settings, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'felizAniversario', label: 'Feliz Aniversário', desc: 'Mensagem carinhosa para os aniversariantes do dia.', color: 'pink', icon: Cake, vars: ['{nome}', '{escola}'] }
];
const insertVariable = (variable: string) => {
if (!editingTemplate) return;
const textarea = document.getElementById('template-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = (templates[editingTemplate.key as keyof typeof templates] as string) || '';
const newText = text.substring(0, start) + variable + text.substring(end);
setTemplates(p => ({ ...p, [editingTemplate.key]: newText }));
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + variable.length, start + variable.length);
}, 10);
};
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex justify-between items-end">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Mensagens</h2>
<p className="text-slate-500 font-medium mt-1">Configure modelos e rotinas de notificação via WhatsApp.</p>
</div>
<button
onClick={handleSave}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-bold flex items-center gap-2 shadow-lg transition-all"
>
<Save size={18} /> Salvar Tudo
</button>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Lado Esquerdo - Configurações e Disparos */}
<div className="space-y-6">
<div className="bg-white border border-slate-200 p-6 rounded-2xl shadow-xl">
<h3 className="font-black text-slate-800 flex items-center gap-2 mb-6 text-sm uppercase tracking-widest text-indigo-600">
<Clock size={18} /> Automação
</h3>
<div className="space-y-5">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={templates.automationRules.sendOnDueDate}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendOnDueDate: e.target.checked } }))}
className="w-5 h-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-bold text-slate-700">Aviso no dia do vencimento</span>
</label>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">1º aviso após</label>
<div className="flex items-center gap-3 text-sm text-slate-700 font-bold">
<input
type="number" min="1" max="30"
value={templates.automationRules.sendDaysAfter}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendDaysAfter: e.target.value } }))}
className="w-16 px-3 py-2 border border-slate-200 rounded-lg text-center bg-white shadow-sm"
/>
<span>dias</span>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">Repetir a cada</label>
<div className="flex items-center gap-3 text-sm text-slate-700 font-bold">
<input
type="number" min="1" max="30"
value={templates.automationRules.repeatEveryDays}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, repeatEveryDays: e.target.value } }))}
className="w-16 px-3 py-2 border border-slate-200 rounded-lg text-center bg-white shadow-sm"
/>
<span>dias</span>
</div>
</div>
</div>
</div>
<div className="bg-emerald-50 border border-emerald-100 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-emerald-800 flex items-center gap-2 mb-4 text-sm uppercase tracking-widest">
<MessageSquare size={18} /> Disparo em Massa
</h3>
<div className="space-y-4">
<select
className="w-full px-3 py-2.5 border border-emerald-200 rounded-xl text-sm bg-white font-bold"
value={targetType}
onChange={(e) => { setTargetType(e.target.value); setTargetId(''); }}
>
<option value="todos">Todos os Alunos</option>
<option value="turma">Uma Turma</option>
<option value="aluno">Um Aluno</option>
</select>
{targetType !== 'todos' && (
<select
className="w-full px-3 py-2.5 border border-emerald-200 rounded-xl text-sm bg-white font-bold"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
>
<option value="">-- Selecione --</option>
{targetType === 'turma'
? data.classes?.map(c => <option key={c.id} value={c.id}>{c.name}</option>)
: data.students?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)
}
</select>
)}
<div>
<label className="block text-[10px] font-black text-emerald-600 uppercase mb-2 ml-1">Mensagem Personalizada</label>
<div className="flex flex-wrap gap-1 mb-2">
{['{nome}', '{matricula}'].map(v => (
<button
key={v}
onClick={() => {
const textarea = document.getElementById('mass-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = messageText.substring(0, start) + v + messageText.substring(end);
setMessageText(newText);
setTimeout(() => { textarea.focus(); textarea.setSelectionRange(start + v.length, start + v.length); }, 10);
}}
className="text-[9px] bg-emerald-100/50 text-emerald-700 px-2 py-1 rounded-md border border-emerald-200 hover:bg-emerald-600 hover:text-white transition-all shadow-sm"
>
{v}
</button>
))}
</div>
<textarea
id="mass-editor"
rows={4}
className="w-full px-3 py-3 border border-emerald-200 rounded-xl text-sm bg-white focus:ring-emerald-500 font-medium shadow-sm"
placeholder="Escreva sua mensagem..."
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
/>
</div>
<button
onClick={handleMassSend}
disabled={isSendingMass || !data.evolutionConfig?.apiUrl}
className={`w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl font-black text-sm text-white transition-all shadow-lg active:scale-95 ${
isSendingMass || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-emerald-600 hover:bg-emerald-700'
}`}
>
{isSendingMass ? 'Iniciando...' : 'Iniciar Disparo'}
</button>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-amber-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest">
<AlertTriangle size={18} /> Inadimplência
</h3>
<button
onClick={handleDispararCobrancas}
disabled={isSending || !data.evolutionConfig?.apiUrl}
className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${
isSending || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-amber-500 hover:bg-amber-600'
}`}
>
{isSending ? 'Processando...' : 'Disparar Cobranças Now'}
</button>
</div>
<div className="bg-pink-50 border border-pink-200 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-pink-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest">
<Cake size={18} /> Aniversariantes
</h3>
<button
onClick={handleSendBirthdays}
disabled={isSendingBdays || birthdayStudents.length === 0 || !data.evolutionConfig?.apiUrl}
className={`w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-xl transition-all active:scale-95 mb-4 ${
isSendingBdays || birthdayStudents.length === 0 || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-pink-500 hover:bg-pink-600'
}`}
>
{isSendingBdays ? 'Enviando...' : 'Parabenizar Todos'}
</button>
<div className="pt-4 border-t border-pink-100">
<label className="block text-[10px] font-black text-pink-400 uppercase tracking-widest mb-3">Próximos do Mês</label>
<div className="space-y-2 max-h-40 overflow-y-auto pr-2 custom-scrollbar">
{(data.students || []).filter(s => {
if (!s.birthDate || s.status !== 'active') return false;
return parseInt(s.birthDate.split('-')[1]) === (new Date().getMonth() + 1);
}).sort((a,b) => parseInt(a.birthDate!.split('-')[2]) - parseInt(b.birthDate!.split('-')[2])).map(s => {
const day = s.birthDate?.split('-')[2];
return (
<div key={s.id} className="flex justify-between items-center text-[10px] font-bold text-pink-700 bg-white/40 p-2 rounded-lg border border-pink-100/50">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-pink-100 rounded-full flex items-center justify-center text-[9px]">{day}</span>
<span className="truncate max-w-[100px]">{s.name}</span>
</div>
<span className="opacity-60">{s.phone || 'S/ Tel'}</span>
</div>
);
})}
</div>
</div>
</div>
</div>
{/* Lado Direito - Cards de Modelos */}
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6">
{templateCards.map((card) => {
const Icon = card.icon;
const colors: any = {
blue: 'bg-blue-50 text-blue-600',
emerald: 'bg-emerald-50 text-emerald-600',
red: 'bg-red-50 text-red-600',
slate: 'bg-slate-50 text-slate-600',
amber: 'bg-amber-50 text-amber-600',
pink: 'bg-pink-50 text-pink-600',
};
return (
<div
key={card.key}
onClick={() => setEditingTemplate(card as any)}
className="bg-white border border-slate-200 rounded-3xl p-6 cursor-pointer transition-all hover:shadow-2xl hover:-translate-y-1 group relative overflow-hidden active:scale-95"
>
<div className={`w-12 h-12 rounded-2xl ${colors[card.color]} flex items-center justify-center mb-5 group-hover:scale-110 transition-transform shadow-sm`}>
<Icon size={24} />
</div>
<h4 className="font-black text-slate-800 text-lg mb-2">{card.label}</h4>
<p className="text-xs text-slate-500 font-medium leading-relaxed">{card.desc}</p>
<div className="mt-6 flex items-center gap-2 text-[10px] font-black text-indigo-600 uppercase tracking-widest border-t border-slate-50 pt-4">
Editar Modelo <Settings size={12} className="group-hover:rotate-45 transition-transform" />
</div>
</div>
);
})}
</div>
</div>
{/* MODAL DE EDIÇÃO */}
{editingTemplate && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-400">
<div className="bg-white rounded-[2.5rem] w-full max-w-2xl shadow-2xl relative overflow-hidden animate-slide-up duration-400 border border-slate-100">
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<div>
<h3 className="text-xl font-black text-slate-800">{editingTemplate.label}</h3>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{editingTemplate.key}</p>
</div>
<button
onClick={() => setEditingTemplate(null)}
className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-2xl shadow-md transition-all hover:rotate-90 border border-slate-100"
>
<X size={20} />
</button>
</div>
<div className="p-8 space-y-6">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 ml-1">Clique para inserir variável</label>
<div className="flex flex-wrap gap-2 text-[10px] font-black">
{editingTemplate.vars.map(v => (
<button
key={v}
onClick={() => insertVariable(v)}
className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-600 hover:text-white transition-all active:scale-95 border border-indigo-100 shadow-sm"
>
{v}
</button>
))}
</div>
</div>
<textarea
id="template-editor"
value={(templates[editingTemplate.key as keyof typeof templates] as string) || ''}
onChange={(e) => setTemplates(p => ({ ...p, [editingTemplate.key]: e.target.value }))}
rows={10}
className="w-full px-6 py-5 bg-slate-50 border-2 border-slate-100 rounded-[2rem] focus:border-indigo-500 focus:bg-white focus:outline-none transition-all text-slate-700 font-medium shadow-inner resize-none"
placeholder="Escreva sua mensagem..."
/>
<div className="flex gap-4">
<button
onClick={() => setEditingTemplate(null)}
className="flex-1 py-4 bg-slate-100 text-slate-500 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all active:scale-95"
>
Cancelar
</button>
<button
onClick={() => { handleSave(); setEditingTemplate(null); }}
className="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-black text-sm hover:bg-indigo-700 shadow-xl shadow-indigo-100 transition-all active:scale-95"
>
Salvar Modelo
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Messages;

View File

@ -0,0 +1,542 @@
import React, { useState } from 'react';
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import {
FileText,
Plus,
Trash2,
ChevronRight,
Save,
GraduationCap,
BookOpen,
User,
X,
Search,
CheckCircle2,
AlertCircle,
Calendar,
Calculator
} from 'lucide-react';
interface ReportCardProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [newSubjectName, setNewSubjectName] = useState('');
const [newPeriodName, setNewPeriodName] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showConfigManager, setShowConfigManager] = useState(false);
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, number>>>({}); // subjectId -> periodId -> value
const subjects = data.subjects || [];
const periods = data.periods || [];
const grades = data.grades || [];
const handleAddSubject = () => {
if (!newSubjectName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome da disciplina.', 'warning');
return;
}
const newSubject: Subject = {
id: crypto.randomUUID(),
name: newSubjectName.trim()
};
const updatedSubjects = [...subjects, newSubject];
updateData({ subjects: updatedSubjects });
dbService.saveData({ ...data, subjects: updatedSubjects });
setNewSubjectName('');
};
const handleAddPeriod = () => {
if (!newPeriodName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome do período.', 'warning');
return;
}
const newPeriod: Period = {
id: crypto.randomUUID(),
name: newPeriodName.trim()
};
const updatedPeriods = [...periods, newPeriod];
updateData({ periods: updatedPeriods });
dbService.saveData({ ...data, periods: updatedPeriods });
setNewPeriodName('');
};
const handleDeleteSubject = (id: string) => {
showConfirm(
'Excluir Disciplina',
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
() => {
const updatedSubjects = subjects.filter(s => s.id !== id);
const updatedGrades = grades.filter(g => g.subjectId !== id);
updateData({ subjects: updatedSubjects, grades: updatedGrades });
dbService.saveData({ ...data, subjects: updatedSubjects, grades: updatedGrades });
}
);
};
const handleDeletePeriod = (id: string) => {
showConfirm(
'Excluir Período',
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
() => {
const updatedPeriods = periods.filter(p => p.id !== id);
const updatedGrades = grades.filter(g => g.period !== id);
updateData({ periods: updatedPeriods, grades: updatedGrades });
dbService.saveData({ ...data, periods: updatedPeriods, grades: updatedGrades });
}
);
};
const handleOpenStudentGrades = (student: Student) => {
setSelectedStudent(student);
const initialGrades: Record<string, Record<string, number>> = {};
subjects.forEach(subject => {
initialGrades[subject.id] = {};
periods.forEach(period => {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id);
initialGrades[subject.id][period.id] = existingGrade ? existingGrade.value : 0;
});
});
setStudentGrades(initialGrades);
};
const handleSaveGrades = () => {
if (!selectedStudent) return;
const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)];
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
Object.entries(periodGrades).forEach(([periodId, value]) => {
if (value > 0) {
newGradesList.push({
id: crypto.randomUUID(),
studentId: selectedStudent.id,
subjectId,
period: periodId,
value
});
}
});
});
updateData({ grades: newGradesList });
dbService.saveData({ ...data, grades: newGradesList });
setSelectedStudent(null);
showAlert('Sucesso', '✅ Notas salvas com sucesso!', 'success');
};
const calculateGeneralAverage = () => {
let totalSum = 0;
let totalCount = 0;
Object.values(studentGrades).forEach(subjectPeriods => {
const periodValues = Object.values(subjectPeriods).filter((v): v is number => typeof v === 'number' && v > 0);
if (periodValues.length > 0) {
const subjectSum = periodValues.reduce((a, b) => a + b, 0);
const subjectAvg = subjectSum / periodValues.length;
totalSum += subjectAvg;
totalCount++;
}
});
return totalCount > 0 ? (totalSum / totalCount).toFixed(2) : '0.00';
};
const getStudentGeneralAverage = (studentId: string) => {
const studentGradesList = grades.filter(g => g.studentId === studentId);
if (studentGradesList.length === 0) return '0.00';
const subjectAverages: number[] = [];
const subjectsWithGrades = new Set(studentGradesList.map(g => g.subjectId));
subjectsWithGrades.forEach(subId => {
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
const sum = subGrades.reduce((a, b) => a + b.value, 0);
subjectAverages.push(sum / subGrades.length);
});
if (subjectAverages.length === 0) return '0.00';
const totalSum = subjectAverages.reduce((a, b) => a + b, 0);
return (totalSum / subjectAverages.length).toFixed(2);
};
const filteredClasses = data.classes.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
</div>
<button
onClick={() => setShowConfigManager(!showConfigManager)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
>
<Plus size={18} /> {showConfigManager ? 'Ver Boletins' : 'Configurações'}
</button>
</header>
{showConfigManager ? (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-6 animate-in slide-in-from-top-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Plus size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setConfigTab('subjects')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
DISCIPLINAS
</button>
<button
onClick={() => setConfigTab('periods')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
PERÍODOS
</button>
</div>
</div>
{configTab === 'subjects' ? (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newSubjectName}
onChange={(e) => setNewSubjectName(e.target.value)}
/>
<button
onClick={handleAddSubject}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{subjects.map(subject => (
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{subject.name}</span>
<button
onClick={() => handleDeleteSubject(subject.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{subjects.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhuma disciplina cadastrada.</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newPeriodName}
onChange={(e) => setNewPeriodName(e.target.value)}
/>
<button
onClick={handleAddPeriod}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{periods.map(period => (
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{period.name}</span>
<button
onClick={() => handleDeletePeriod(period.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{periods.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhum período cadastrado.</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-6">
{!selectedClass ? (
<>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar turmas..."
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredClasses.map(cls => {
const course = data.courses.find(c => c.id === cls.courseId);
const studentCount = data.students.filter(s => s.classId === cls.id).length;
return (
<div
key={cls.id}
onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<BookOpen size={80} />
</div>
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<GraduationCap size={24} />
</div>
<div>
<h3 className="font-black text-slate-800 text-lg">{cls.name}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{course?.name || 'Curso não encontrado'}</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500 font-medium">{studentCount} Alunos Matriculados</span>
<ChevronRight size={20} className="text-slate-300 group-hover:text-indigo-500 transform group-hover:translate-x-1 transition-all" />
</div>
</div>
);
})}
</div>
</>
) : (
<div className="space-y-6 animate-in slide-in-from-left-4">
<button
onClick={() => setSelectedClass(null)}
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
>
<X size={18} /> Voltar para Turmas
</button>
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl">
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-2xl font-black text-slate-800">{selectedClass.name}</h3>
<p className="text-slate-500 font-medium">Selecione um aluno para preencher as notas.</p>
</div>
<div className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-xl font-bold text-sm">
{data.students.filter(s => s.classId === selectedClass.id).length} Alunos
</div>
</div>
<div className="space-y-3">
{data.students
.filter(s => s.classId === selectedClass.id)
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<div
key={student.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={student.photo} alt={student.name} className="w-full h-full object-cover" />
) : (
<User size={20} />
)}
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div>
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
<div className="hidden sm:flex flex-col items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
{getStudentGeneralAverage(student.id)}
</span>
</div>
<button
onClick={() => handleOpenStudentGrades(student)}
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
>
<FileText size={14} /> Notas
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* GRADES MODAL */}
{selectedStudent && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] animate-slide-up">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white shadow-sm flex items-center justify-center text-indigo-600">
<GraduationCap size={28} />
</div>
<div>
<h3 className="text-xl font-black text-slate-800">{selectedStudent.name}</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar {selectedClass?.name}</p>
</div>
</div>
<button
onClick={() => setSelectedStudent(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
>
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-8 custom-scrollbar">
{subjects.length === 0 || periods.length === 0 ? (
<div className="text-center py-12 space-y-4">
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
<p className="text-slate-500 font-medium">
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
Por favor, complete as configurações primeiro.
</p>
<button
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
>
Ir para Configurações
</button>
</div>
) : (
<div className="space-y-6">
{subjects.map(subject => {
// Encontrar provas vinculadas a esta disciplina
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{linkedExams.length > 0 && (
<div className="px-3 py-1 bg-violet-50 border border-violet-200 rounded-lg text-[10px] font-black text-violet-600 flex items-center gap-1">
<FileText size={12} />
{linkedExams.length} {linkedExams.length === 1 ? 'Prova' : 'Provas'}
</div>
)}
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
MÉDIA: {(() => {
const subjectGrades = studentGrades[subject.id] || {};
const vals = Object.values(subjectGrades).filter((v): v is number => typeof v === 'number' && v > 0);
return vals.length > 0 ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : '0.0';
})()}
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{periods.map(period => {
// Verificar se há uma prova vinculada a esta disciplina+período
const linkedExam = (data.exams || []).find(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
return (
<div key={period.id} className="space-y-1.5">
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">{period.name}</label>
<input
type="number"
min="0"
max="10"
step="0.1"
className={`w-full px-3 py-2 bg-white border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center ${linkedExam ? 'border-violet-300 ring-1 ring-violet-100' : 'border-slate-200'}`}
value={studentGrades[subject.id]?.[period.id] || 0}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: val
}
}));
}}
/>
{linkedExam && (
<p className="text-[9px] font-bold text-violet-500 truncate ml-1" title={linkedExam.title}>
📝 {linkedExam.title}
</p>
)}
</div>
);
})}
</div>
</div>
);
})}
{/* General Average Summary */}
<div className="bg-indigo-600 rounded-2xl p-6 text-white flex items-center justify-between shadow-xl shadow-indigo-100">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 rounded-xl">
<Calculator size={24} />
</div>
<div>
<h4 className="text-lg font-black">Média Geral</h4>
<p className="text-xs text-indigo-100 font-medium">Calculada automaticamente com base em todas as disciplinas.</p>
</div>
</div>
<div className="text-4xl font-black">
{calculateGeneralAverage()}
</div>
</div>
</div>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setSelectedStudent(null)}
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
>
Cancelar
</button>
<button
onClick={handleSaveGrades}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
>
<Save size={18} /> Salvar Notas
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ReportCard;

View File

@ -0,0 +1,115 @@
import React, { useState, useRef, useEffect } from 'react';
import { Search, ChevronDown, X } from 'lucide-react';
interface Option {
id: string;
name: string;
subtext?: string;
}
interface SearchableSelectProps {
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder: string;
label: string;
required?: boolean;
}
const SearchableSelect: React.FC<SearchableSelectProps> = ({
options,
value,
onChange,
placeholder,
label,
required = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find(opt => opt.id === value);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredOptions = options.filter(opt =>
(opt.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(opt.subtext && (opt.subtext || '').toLowerCase().includes((searchTerm || '').toLowerCase()))
);
return (
<div className="relative" ref={containerRef}>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div
onClick={() => setIsOpen(!isOpen)}
className={`w-full px-4 py-3 bg-slate-50 border ${isOpen ? 'border-indigo-500 ring-2 ring-indigo-500/10' : 'border-slate-200'} rounded-xl cursor-pointer flex items-center justify-between transition-all text-sm`}
>
<span className={selectedOption ? 'text-slate-800 font-medium' : 'text-slate-400'}>
{selectedOption ? selectedOption.name : placeholder}
</span>
<ChevronDown size={18} className={`text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</div>
{isOpen && (
<div className="absolute z-[110] w-full mt-2 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-3 border-b border-slate-100 bg-slate-50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
autoFocus
type="text"
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
placeholder="Pesquisar..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{filteredOptions.length > 0 ? (
filteredOptions.map(option => (
<div
key={option.id}
onClick={() => {
onChange(option.id);
setIsOpen(false);
setSearchTerm('');
}}
className={`px-4 py-3 hover:bg-indigo-50 cursor-pointer transition-colors flex flex-col ${value === option.id ? 'bg-indigo-50 border-l-4 border-indigo-600' : ''}`}
>
<span className={`text-sm ${value === option.id ? 'font-bold text-indigo-600' : 'font-medium text-slate-700'}`}>
{option.name}
</span>
{option.subtext && (
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
{option.subtext}
</span>
)}
</div>
))
) : (
<div className="px-4 py-6 text-center text-slate-400 text-sm italic">
Nenhum resultado encontrado.
</div>
)}
</div>
</div>
)}
</div>
);
};
export default SearchableSelect;

View File

@ -0,0 +1,717 @@
import React, { useState, useMemo } from 'react';
import { SchoolData, SchoolProfile } from '../types';
import { dbService } from '../services/dbService';
import { Download, Upload, Trash2, Database, School, Camera, FileText, Info, AlertTriangle, X, CheckCircle, AlertCircle, Cloud, HelpCircle, RefreshCw, Plus, User } from 'lucide-react';
import { isSupabaseConfigured, uploadLogo } from '../services/supabase';
import { useDialog } from '../DialogContext';
import imageCompression from 'browser-image-compression';
interface SettingsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
setData: (data: SchoolData) => void;
}
const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
const { showAlert, showConfirm } = useDialog();
const [selectedProfileId, setSelectedProfileId] = useState<string>(data.profile.id || 'main-school');
const [profiles, setProfiles] = useState<SchoolProfile[]>(data.profiles || [data.profile]);
const [globalLogo, setGlobalLogo] = useState<string>(data.logo || '');
const currentProfile = profiles.find(p => p.id === selectedProfileId) || profiles[0];
const currentDirector = useMemo(() => {
const employees = data.employees || [];
const categories = data.employeeCategories || [];
return employees.find(e => {
const cat = categories.find(c => c.id === e.categoryId);
const catName = cat?.name.toLowerCase() || '';
const empName = e.name.toLowerCase();
return catName.includes('diretor') || catName.includes('diretoria') ||
empName.includes('diretor') || empName.includes('diretoria');
});
}, [data.employees, data.employeeCategories]);
const [profileForm, setProfileForm] = useState<SchoolProfile>(currentProfile);
const [showEvolutionModal, setShowEvolutionModal] = useState(false);
const [evolutionForm, setEvolutionForm] = useState({
apiUrl: data.evolutionConfig?.apiUrl || '',
instanceName: data.evolutionConfig?.instanceName || '',
apiKey: data.evolutionConfig?.apiKey || ''
});
const saveEvolutionConfig = () => {
updateData({ evolutionConfig: evolutionForm });
setShowEvolutionModal(false);
showAlert('Sucesso', 'Configurações da Evolution API salvas!', 'success');
};
React.useEffect(() => {
setProfileForm(currentProfile);
}, [selectedProfileId, profiles]);
React.useEffect(() => {
setGlobalLogo(data.logo || '');
}, [data.logo]);
const [activeTab, setActiveTab] = useState<'perfil' | 'monitoramento'>('perfil');
const [apiLogs, setApiLogs] = useState<any[]>([]);
React.useEffect(() => {
if (activeTab === 'monitoramento') {
fetch('/api/logs')
.then(res => res.json())
.then(data => setApiLogs(data))
.catch(err => console.error('Erro ao buscar logs:', err));
}
}, [activeTab]);
const validateCNPJ = (cnpj: string) => {
cnpj = cnpj.replace(/[^\d]+/g, '');
if (cnpj === '' || cnpj.length !== 14) return false;
if (/^(\d)\1+$/.test(cnpj)) return false;
let tamanho = cnpj.length - 2;
let numeros = cnpj.substring(0, tamanho);
let digitos = cnpj.substring(tamanho);
let soma = 0;
let pos = tamanho - 7;
for (let i = tamanho; i >= 1; i--) {
soma += parseInt(numeros.charAt(tamanho - i)) * pos--;
if (pos < 2) pos = 9;
}
let resultado = soma % 11 < 2 ? 0 : 11 - (soma % 11);
if (resultado !== parseInt(digitos.charAt(0))) return false;
tamanho = tamanho + 1;
numeros = cnpj.substring(0, tamanho);
soma = 0;
pos = tamanho - 7;
for (let i = tamanho; i >= 1; i--) {
soma += parseInt(numeros.charAt(tamanho - i)) * pos--;
if (pos < 2) pos = 9;
}
resultado = soma % 11 < 2 ? 0 : 11 - (soma % 11);
if (resultado !== parseInt(digitos.charAt(1))) return false;
return true;
};
const handleZipChange = async (zip: string) => {
const cleanZip = zip.replace(/\D/g, '');
setProfileForm(prev => ({ ...prev, zip: zip.replace(/^(\d{5})(\d)/, '$1-$2').slice(0, 9) }));
if (cleanZip.length === 8) {
try {
const response = await fetch(`https://viacep.com.br/ws/${cleanZip}/json/`);
const data = await response.json();
if (!data.erro) {
setProfileForm(prev => ({
...prev,
address: data.logradouro,
city: data.localidade,
state: data.uf
}));
}
} catch (error) {
console.error('Erro ao buscar CEP:', error);
}
}
};
const saveProfile = () => {
if (!validateCNPJ(profileForm.cnpj)) {
showAlert('Erro', 'CNPJ inválido. Por favor, insira um CNPJ verdadeiro.', 'error');
return;
}
// Check if trying to set as Matriz but another Matriz already exists
if (profileForm.type === 'matriz') {
const otherMatriz = profiles.find(p => p.type === 'matriz' && p.id !== profileForm.id);
if (otherMatriz) {
showAlert('Erro', `Já existe uma matriz cadastrada (${otherMatriz.name}). Só é permitida uma matriz.`, 'error');
return;
}
}
const updatedProfiles = profiles.map(p => p.id === profileForm.id ? profileForm : p);
const mainProfile = updatedProfiles.find(p => p.type === 'matriz') || updatedProfiles[0];
setProfiles(updatedProfiles);
updateData({ profiles: updatedProfiles, profile: mainProfile });
showAlert('Sucesso', 'Configurações salvas com sucesso!', 'success');
};
const addNewInstitution = () => {
const newId = `school-${Date.now()}`;
const newProfile: SchoolProfile = {
id: newId,
name: 'Nova Instituição',
address: '',
city: '',
state: '',
zip: '',
cnpj: '',
phone: '',
email: '',
type: 'filial'
};
setProfiles([...profiles, newProfile]);
setSelectedProfileId(newId);
};
const deleteInstitution = (id: string) => {
if (profiles.length <= 1) {
showAlert('Erro', 'É necessário ter pelo menos uma instituição cadastrada.', 'error');
return;
}
const profileToDelete = profiles.find(p => p.id === id);
if (profileToDelete?.type === 'matriz') {
showAlert('Erro', 'Não é possível excluir a instituição matriz. Altere outra para matriz primeiro.', 'error');
return;
}
showConfirm(
'Excluir Instituição?',
`Tem certeza que deseja excluir a instituição "${profileToDelete?.name}"?`,
() => {
const updatedProfiles = profiles.filter(p => p.id !== id);
setProfiles(updatedProfiles);
setSelectedProfileId(updatedProfiles[0].id);
updateData({ profiles: updatedProfiles, profile: updatedProfiles[0] });
}
);
};
const [isSyncing, setIsSyncing] = useState(false);
const supabaseConfigured = useMemo(() => isSupabaseConfigured(), []);
const [showImportModal, setShowImportModal] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const downloadSupabaseSQL = () => {
const sql = `-- Create the table for storing the entire application state as a JSON blob
create table if not exists school_data (
id bigint primary key,
data jsonb not null default '{}'::jsonb,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Insert the initial row (id=1) if it doesn't exist so the app has something to fetch/update
insert into school_data (id, data)
values (1, '{}'::jsonb)
on conflict (id) do nothing;
-- Enable Row Level Security (RLS)
alter table school_data enable row level security;
-- Create a policy that allows anyone to read/write (for development/demo purposes)
-- In a real production app, you would restrict this to authenticated users
create policy "Enable read access for all users"
on school_data for select
using (true);
create policy "Enable insert access for all users"
on school_data for insert
with check (true);
create policy "Enable update access for all users"
on school_data for update
using (true);`;
const blob = new Blob([sql], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'supabase_setup.sql';
a.click();
URL.revokeObjectURL(url);
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setShowImportModal(false);
setIsClosing(false);
}, 300);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
showAlert('Aguarde', 'Fazendo upload e otimizando a logo...', 'info');
// Compression options
const options = {
maxSizeMB: 0.1, // 100KB
maxWidthOrHeight: 500,
useWebWorker: true
};
const compressedFile = await imageCompression(file, options);
let logoUrl = '';
// Try to upload to Supabase if configured
if (supabaseConfigured) {
const url = await uploadLogo(compressedFile);
if (url) {
logoUrl = url;
}
}
// Fallback to base64 if Supabase upload failed or not configured
if (!logoUrl) {
const reader = new FileReader();
logoUrl = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(compressedFile);
});
}
setGlobalLogo(logoUrl);
updateData({ logo: logoUrl });
showAlert('Sucesso', 'Logo atualizada com sucesso!', 'success');
} catch (error) {
console.error('Erro ao fazer upload da imagem:', error);
showAlert('Erro', 'Falha ao processar e salvar a imagem.', 'error');
}
}
};
const handleReset = async () => {
await dbService.resetData();
window.location.reload();
};
const formatPhone = (value: string) => {
return value
.replace(/\D/g, '')
.replace(/^(\d{2})(\d)/, '($1) $2 ')
.replace(/(\d{4})(\d)/, '$1-$2')
.slice(0, 16);
};
const handleManualSync = async () => {
if (!supabaseConfigured) return;
setIsSyncing(true);
try {
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
setData(cloudData);
await dbService.saveData(cloudData);
showAlert('Sucesso', '✅ Dados sincronizados com a nuvem!', 'success');
} else {
// If no cloud data, maybe we should push local data?
await dbService.saveToCloud(data);
showAlert('Sucesso', '✅ Dados locais enviados para a nuvem!', 'success');
}
} catch (error) {
showAlert('Erro', '❌ Falha na sincronização. Verifique sua conexão e configurações.', 'error');
} finally {
setIsSyncing(false);
}
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm text-sm";
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Configurações</h2>
<p className="text-slate-500 font-medium">Gerencie o perfil da escola, modelo de contrato e dados.</p>
<div className="flex gap-4 mt-6 border-b border-slate-200">
<button
onClick={() => setActiveTab('perfil')}
className={`pb-2 font-bold text-sm ${activeTab === 'perfil' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Perfil
</button>
<button
onClick={() => setActiveTab('monitoramento')}
className={`pb-2 font-bold text-sm ${activeTab === 'monitoramento' ? 'text-indigo-600 border-b-2 border-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Monitoramento de API
</button>
</div>
</header>
{activeTab === 'perfil' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white p-8 rounded-xl border border-slate-200 shadow-xl space-y-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-3 bg-indigo-50 rounded-lg">
<School size={24} />
</div>
<h3 className="text-xl font-black text-slate-800">Perfil da Instituição</h3>
</div>
<button
onClick={addNewInstitution}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-all font-bold text-xs shadow-md"
>
<Plus size={16} /> Nova Instituição
</button>
</div>
{/* Institution Selector */}
<div className="flex flex-wrap gap-2 mb-6">
{profiles.map(p => (
<div key={p.id} className="flex items-center">
<button
onClick={() => setSelectedProfileId(p.id)}
className={`px-4 py-2 rounded-lg font-bold text-xs transition-all border ${
selectedProfileId === p.id
? 'bg-indigo-600 text-white border-indigo-600 shadow-md'
: 'bg-white text-slate-600 border-slate-200 hover:border-indigo-300'
}`}
>
{p.name} {p.type === 'matriz' && '(Matriz)'}
</button>
{p.id !== selectedProfileId && p.type !== 'matriz' && (
<button
onClick={(e) => { e.stopPropagation(); deleteInstitution(p.id); }}
className="ml-1 p-1 text-red-400 hover:text-red-600 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="flex flex-col items-center gap-4">
<div className="w-40 h-40 rounded-xl bg-slate-50 border-2 border-dashed border-slate-200 flex items-center justify-center overflow-hidden relative group shadow-inner">
{globalLogo ? (
<img src={globalLogo} alt="Logo" className="w-full h-full object-contain p-2" />
) : (
<div className="text-slate-300 text-center p-4">
<Camera size={40} className="mx-auto mb-2 opacity-20" />
<span className="text-[10px] font-bold uppercase text-slate-500">Logo Global</span>
</div>
)}
<label className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer text-white">
<Upload size={24} />
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
</label>
</div>
<p className="text-[10px] font-bold text-slate-400 uppercase text-center">Logo única para todas as unidades</p>
</div>
<div className="flex-1 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Nome da Escola</label>
<input className={inputClass} value={profileForm.name} onChange={e => setProfileForm({...profileForm, name: e.target.value})} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">CNPJ</label>
<input className={inputClass} placeholder="00.000.000/0001-00" value={profileForm.cnpj} onChange={e => setProfileForm({...profileForm, cnpj: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">CEP</label>
<input className={inputClass} placeholder="00000-000" value={profileForm.zip} onChange={e => handleZipChange(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Endereço</label>
<input className={inputClass} value={profileForm.address} onChange={e => setProfileForm({...profileForm, address: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Cidade</label>
<input className={inputClass} value={profileForm.city} onChange={e => setProfileForm({...profileForm, city: e.target.value})} />
</div>
<div className="md:col-span-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Estado (UF)</label>
<input className={inputClass} placeholder="UF" value={profileForm.state} onChange={e => setProfileForm({...profileForm, state: e.target.value.toUpperCase().slice(0, 2)})} />
</div>
<div className="md:col-span-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Tipo</label>
<select
className={inputClass}
value={profileForm.type}
onChange={e => setProfileForm({...profileForm, type: e.target.value as 'matriz' | 'filial'})}
>
<option value="matriz">Matriz</option>
<option value="filial">Filial</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Telefone</label>
<input className={inputClass} placeholder="(00) 0 0000-0000" value={profileForm.phone} onChange={e => setProfileForm({...profileForm, phone: formatPhone(e.target.value)})} maxLength={16} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Email</label>
<input className={inputClass} placeholder="Email" value={profileForm.email} onChange={e => setProfileForm({...profileForm, email: e.target.value})} />
</div>
</div>
</div>
</div>
<div className="pt-4">
<button
onClick={saveProfile}
className="w-full py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-sm"
>
Salvar Perfil da Instituição
</button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Cloud size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Sincronização Nuvem</h3>
</div>
<div className={`p-4 rounded-lg border ${supabaseConfigured ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-slate-50 border-slate-200 text-slate-500'}`}>
<div className="flex items-center gap-2 font-bold text-sm mb-1">
{supabaseConfigured ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{supabaseConfigured ? 'Conectado ao Supabase' : 'Não Conectado'}
</div>
<p className="text-xs opacity-80 leading-relaxed">
{supabaseConfigured
? 'Seus dados estão sendo salvos automaticamente na nuvem.'
: 'Para habilitar o backup na nuvem, configure as variáveis de ambiente VITE_SUPABASE_URL e VITE_SUPABASE_KEY.'}
</p>
{!supabaseConfigured && (
<div className="mt-3 text-[10px] bg-white p-2 rounded border border-slate-200 font-mono text-slate-400 break-all">
VITE_SUPABASE_URL=...<br/>
VITE_SUPABASE_KEY=...
</div>
)}
</div>
{supabaseConfigured && (
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-100 text-emerald-700">
<p className="text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
<CheckCircle size={14} /> Sincronização Automática Ativa
</p>
</div>
)}
<button
onClick={downloadSupabaseSQL}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition-all font-bold text-xs border border-indigo-100"
>
<FileText size={16} /> Baixar Script SQL Supabase
</button>
</div>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Database size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Dados do System</h3>
</div>
<button onClick={async () => await dbService.exportData()} className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-all font-bold text-xs">
<Download size={16} /> Exportar Backup
</button>
<label className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 text-slate-600 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors font-bold text-xs">
<Upload size={16} /> Importar Backup
<input type="file" className="hidden" accept=".json" onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
showConfirm(
'Substituir Dados?',
'⚠️ Tem certeza que deseja substituir todos os dados atuais? Esta ação não pode ser desfeita.',
async () => {
await dbService.importData(file);
const newData = await dbService.initData();
setData(newData);
showAlert('Sucesso', '✅ Dados restaurados com sucesso!', 'success');
}
);
}
}} />
</label>
</div>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<FileText size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Evolution API</h3>
</div>
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200">
{data.evolutionConfig?.apiUrl ? (
<div className="space-y-2 text-sm text-slate-600">
<p><strong>URL:</strong> {data.evolutionConfig.apiUrl}</p>
<p><strong>Instância:</strong> {data.evolutionConfig.instanceName}</p>
<p><strong>API Key:</strong> </p>
</div>
) : (
<p className="text-xs text-slate-500 text-center">Nenhuma credencial configurada.</p>
)}
</div>
<button
onClick={() => setShowEvolutionModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-all font-bold text-xs shadow-md"
>
<Plus size={16} /> Configurar Credenciais
</button>
</div>
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 p-6 rounded-xl border border-indigo-100 shadow-xl space-y-4">
<div className="flex items-center gap-3 text-indigo-800">
<div className="p-2 bg-white rounded-lg shadow-sm">
<User size={20} className="text-indigo-600" />
</div>
<h3 className="text-lg font-black text-slate-800">Responsável Legal / Diretor</h3>
</div>
<div className="p-4 rounded-lg bg-white border border-indigo-50 shadow-sm">
{currentDirector ? (
<div className="space-y-2 text-sm text-slate-700">
<p><strong>Nome:</strong> {currentDirector.name}</p>
<p><strong>CPF:</strong> {currentDirector.cpf}</p>
<p className="text-xs text-indigo-500 mt-2 font-medium bg-indigo-50 inline-block px-2 py-1 rounded">Este responsável assinará automaticamente os documentos.</p>
</div>
) : (
<p className="text-xs text-slate-500 text-center">Nenhum diretor localizado. Cadastre um funcionário como Diretor na aba Funcionários.</p>
)}
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl">
<button
onClick={() => showConfirm(
'Resetar Sistema',
'Isso apagará TODOS os dados cadastrados. Não há como desfazer.',
handleReset,
'alert'
)}
className="w-full py-3 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors font-bold text-xs flex items-center justify-center gap-2"
>
<Trash2 size={16} /> Resetar Fábrica
</button>
</div>
</div>
</div>
) : (
<div className="bg-white p-8 rounded-xl border border-slate-200 shadow-xl">
<h3 className="text-xl font-black text-slate-800 mb-6">Logs de API</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 uppercase text-[10px] font-black tracking-wider">
<tr>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Serviço</th>
<th className="px-4 py-3">Ação</th>
<th className="px-4 py-3">Detalhes</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{apiLogs.map((log, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">{new Date(log.date).toLocaleString()}</td>
<td className="px-4 py-3 font-bold text-indigo-600">{log.service}</td>
<td className="px-4 py-3 text-slate-700">{log.action}</td>
<td className="px-4 py-3 text-slate-600 text-xs font-mono">{JSON.stringify(log.details)}</td>
</tr>
))}
{apiLogs.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-slate-400">Nenhum log encontrado.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Evolution API Modal */}
{showEvolutionModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-slide-up">
<div className="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 className="text-xl font-bold text-slate-800">Credenciais Evolution API</h3>
<button
onClick={() => setShowEvolutionModal(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">URL da API</label>
<input
type="text"
value={evolutionForm.apiUrl}
onChange={e => setEvolutionForm({...evolutionForm, apiUrl: e.target.value})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
placeholder="https://api.evolution.com"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Nome da Instância</label>
<input
type="text"
value={evolutionForm.instanceName}
onChange={e => setEvolutionForm({...evolutionForm, instanceName: e.target.value})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
placeholder="minha-instancia"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">API Key</label>
<input
type="password"
value={evolutionForm.apiKey}
onChange={e => setEvolutionForm({...evolutionForm, apiKey: e.target.value})}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:bg-white transition-all shadow-sm text-sm"
placeholder="••••••••••••"
/>
</div>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
<button
onClick={() => setShowEvolutionModal(false)}
className="px-5 py-2.5 text-slate-600 font-semibold hover:bg-slate-200 rounded-xl transition-all"
>
Cancelar
</button>
<button
onClick={saveEvolutionConfig}
className="px-6 py-2.5 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 shadow-md transition-all flex items-center gap-2"
>
<CheckCircle size={18} /> Confirmar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Settings;

View File

@ -0,0 +1,173 @@
import React, { useState } from 'react';
import {
LayoutDashboard,
Users,
BookOpen,
CircleDollarSign,
Settings,
FileSignature,
Award,
Camera,
ListChecks,
ChevronLeft,
ChevronRight,
Menu,
X,
GraduationCap,
Shield,
FileText,
Cloud,
CloudOff,
Library,
Briefcase,
LogOut,
MessageSquare,
ClipboardList
} from 'lucide-react';
import { isSupabaseConfigured } from '../services/supabase';
import { View, User } from '../types';
interface SidebarProps {
currentView: View;
setView: (view: View) => void;
user: User | null;
logo?: string;
}
const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const items = [
{ id: View.Dashboard, icon: LayoutDashboard, label: 'Dashboard' },
{ id: View.Courses, icon: GraduationCap, label: 'Cursos' },
{ id: View.Students, icon: Users, label: 'Alunos' },
{ id: View.Classes, icon: BookOpen, label: 'Turmas' },
{ id: View.Exams, icon: ClipboardList, label: 'Avaliações' },
{ id: View.ReportCard, icon: FileText, label: 'Boletim Escolar' },
{ id: View.Finance, icon: CircleDollarSign, label: 'Financeiro' },
{ id: View.Contracts, icon: FileSignature, label: 'Contratos' },
{ id: View.Certificates, icon: Award, label: 'Certificados' },
{ id: View.Attendance, icon: Camera, label: 'Frequência' },
{ id: View.AttendanceQuery, icon: ListChecks, label: 'Registro de Frequência' },
{ id: View.Handouts, icon: Library, label: 'Apostilas' },
{ id: View.Employees, icon: Briefcase, label: 'Funcionários' },
{ id: View.Users, icon: Shield, label: 'Usuários' },
{ id: View.Messages, icon: MessageSquare, label: 'Mensagens' },
{ id: View.Settings, icon: Settings, label: 'Configurações' },
];
const toggleMobile = () => setIsMobileOpen(!isMobileOpen);
const supabaseConfigured = isSupabaseConfigured();
return (
<>
{/* Mobile Toggle */}
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-slate-200 p-4 flex justify-between items-center z-40">
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2">
{logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain" /> : <BookOpen size={24} />}
<span>EduManager</span>
</h1>
<button onClick={toggleMobile} className="p-2 text-slate-600">
{isMobileOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* Sidebar Overlay for Mobile */}
{isMobileOpen && (
<div
className="md:hidden fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* Sidebar Container */}
<aside className={`
fixed md:static inset-y-0 left-0 z-50 bg-white border-r border-slate-200 flex flex-col transition-all duration-300
${isMobileOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:w-20' : 'md:w-64'}
`}>
<div className={`p-6 border-b border-slate-200 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{(!isCollapsed || isMobileOpen) && (
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2 overflow-hidden whitespace-nowrap">
{logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain flex-shrink-0" /> : <BookOpen size={24} className="flex-shrink-0" />}
<span>EduManager</span>
</h1>
)}
{isCollapsed && !isMobileOpen && (logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain" /> : <BookOpen size={24} className="text-indigo-600" />)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 ml-2"
>
{isCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
onClick={() => {
setView(item.id);
setIsMobileOpen(false);
}}
title={isCollapsed ? item.label : ''}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${
currentView === item.id
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
} ${isCollapsed && !isMobileOpen ? 'justify-center px-0' : ''}`}
>
<item.icon size={22} className="flex-shrink-0" />
{(!isCollapsed || isMobileOpen) && <span>{item.label}</span>}
</button>
))}
</nav>
<div className="p-4 border-t border-slate-100 space-y-3">
<div className={`flex items-center gap-3 ${isCollapsed ? 'justify-center' : 'px-4 py-2'}`}>
{user?.photoURL ? (
<img
src={user.photoURL}
alt={user.displayName || user.name}
className="w-8 h-8 rounded-full object-cover border border-slate-200"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-xs">
{user?.name?.substring(0, 2).toUpperCase() || 'AD'}
</div>
)}
{!isCollapsed && (
<div className="overflow-hidden flex-1">
<p className="text-xs font-bold text-slate-900 truncate">{user?.displayName || user?.name || 'Administrador'}</p>
<p className="text-[10px] text-slate-500 truncate uppercase tracking-tighter">{user?.role === 'admin' ? 'Administrador' : 'Usuário'}</p>
</div>
)}
{!isCollapsed && (
<button
onClick={() => window.location.reload()}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-lg transition-all"
title="Sair"
>
<LogOut size={16} />
</button>
)}
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${supabaseConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-50 text-slate-400'} ${isCollapsed ? 'justify-center px-0' : ''}`}>
{supabaseConfigured ? <Cloud size={16} /> : <CloudOff size={16} />}
{!isCollapsed && (
<span className="text-[10px] font-black uppercase tracking-widest">
{supabaseConfigured ? 'Nuvem Ativa' : 'Nuvem Inativa'}
</span>
)}
</div>
</div>
</aside>
</>
);
};
export default Sidebar;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,355 @@
import React, { useState, useRef } from 'react';
import { SchoolData, User } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Shield, Lock, User as UserIcon, AlertTriangle, Camera, Loader2 } from 'lucide-react';
import imageCompression from 'browser-image-compression';
import { uploadProfilePicture } from '../services/supabase';
interface UserManagementProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const UserManagement: React.FC<UserManagementProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState({
name: '',
displayName: '',
password: '',
cpf: '',
photoURL: '',
role: 'user' as 'admin' | 'user'
});
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingUser(null);
setFormData({ name: '', displayName: '', password: '', cpf: '', photoURL: '', role: 'user' });
}, 400);
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({
name: user.name,
displayName: user.displayName || '',
password: user.password,
cpf: user.cpf || '',
photoURL: user.photoURL || '',
role: user.role || 'user'
});
setIsModalOpen(true);
};
const handleDelete = (user: User) => {
if (data.users.length <= 1) {
showAlert('Atenção', '⚠️ Você não pode excluir o último usuário do sistema.', 'warning');
return;
}
showConfirm(
'Remover Usuário',
`Tem certeza que deseja remover o acesso de ${user.name}?`,
() => {
const updatedUsers = data.users.filter(u => u.id !== user.id);
updateData({ users: updatedUsers });
}
);
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsUploading(true);
// Compression options
const options = {
maxSizeMB: 0.1,
maxWidthOrHeight: 400,
useWebWorker: true
};
const compressedFile = await imageCompression(file, options);
// Upload to Supabase
const url = await uploadProfilePicture(editingUser?.id || 'new-user', compressedFile);
if (url) {
setFormData(prev => ({ ...prev, photoURL: url }));
} else {
showAlert('Erro', 'Não foi possível fazer o upload da imagem. Verifique a configuração do Supabase.', 'error');
}
} catch (error) {
console.error('Compression/Upload error:', error);
showAlert('Erro', 'Erro ao processar imagem.', 'error');
} finally {
setIsUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (formData.name.length < 3) {
showAlert('Atenção', '⚠️ O nome deve ter no mínimo 3 caracteres.', 'warning');
return;
}
if (formData.password.length < 3) {
showAlert('Atenção', '⚠️ A senha deve ter no mínimo 3 caracteres.', 'warning');
return;
}
if (!formData.cpf || formData.cpf.replace(/\D/g, '').length !== 11) {
showAlert('Atenção', '⚠️ O CPF é obrigatório e deve ter 11 dígitos.', 'warning');
return;
}
if (editingUser) {
// Check if name is taken by another user
const exists = data.users.some(u => (u.name || '').toLowerCase() === (formData.name || '').toLowerCase() && u.id !== editingUser.id);
if (exists) {
showAlert('Atenção', '⚠️ Este nome de usuário já está em uso.', 'warning');
return;
}
const updatedUsers = data.users.map(u =>
u.id === editingUser.id ? {
...u,
name: formData.name,
displayName: formData.displayName,
password: formData.password,
cpf: formData.cpf,
photoURL: formData.photoURL,
role: formData.role
} : u
);
updateData({ users: updatedUsers });
} else {
// Check if name is taken
const exists = data.users.some(u => (u.name || '').toLowerCase() === (formData.name || '').toLowerCase());
if (exists) {
showAlert('Atenção', '⚠️ Este nome de usuário já está em uso.', 'warning');
return;
}
const newUser: User = {
id: crypto.randomUUID(),
name: formData.name,
displayName: formData.displayName,
password: formData.password,
cpf: formData.cpf,
photoURL: formData.photoURL,
role: formData.role
};
updateData({ users: [...data.users, newUser] });
}
closeModal();
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Usuários do Sistema</h2>
<p className="text-slate-500">Gerencie quem tem acesso administrativo ao EduManager.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Usuário
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.users.map(user => (
<div key={user.id} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full overflow-hidden bg-indigo-50 text-indigo-600 flex items-center justify-center border border-slate-100">
{user.photoURL ? (
<img src={user.photoURL} alt={user.name} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : (
<Shield size={24} />
)}
</div>
<div>
<h3 className="font-bold text-slate-900">{user.displayName || user.name}</h3>
<p className="text-xs text-slate-400 font-mono">@{user.name} {user.role === 'admin' ? 'Admin' : 'Usuário'}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(user)}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"
title="Editar Senha/Nome"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(user)}
className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"
title="Remover Acesso"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
{/* CREATE/EDIT MODAL */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-md shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800 tracking-tight">
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
</h3>
<p className="text-xs text-slate-500">Defina as credenciais de acesso.</p>
</div>
<button onClick={closeModal} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Photo Upload */}
<div className="flex flex-col items-center mb-6">
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-slate-100 border-2 border-indigo-100 flex items-center justify-center overflow-hidden">
{formData.photoURL ? (
<img src={formData.photoURL} alt="Preview" className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : (
<UserIcon size={40} className="text-slate-300" />
)}
{isUploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="text-white animate-spin" size={24} />
</div>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute bottom-0 right-0 p-2 bg-indigo-600 text-white rounded-full shadow-lg hover:bg-indigo-700 transition-all"
>
<Camera size={16} />
</button>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handlePhotoUpload}
/>
<p className="text-[10px] text-slate-400 mt-2 uppercase font-bold tracking-widest">Foto de Perfil</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome de Usuário</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="Ex: admin"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome Completo</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="Ex: João Silva"
value={formData.displayName}
onChange={e => setFormData({...formData, displayName: e.target.value})}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">CPF do Usuário</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="000.000.000-00"
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({...formData, cpf: val});
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nível de Acesso</label>
<select
className={inputClass}
value={formData.role}
onChange={e => setFormData({...formData, role: e.target.value as 'admin' | 'user'})}
>
<option value="user">Usuário Comum</option>
<option value="admin">Administrador</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Senha de Acesso</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
required
className={`${inputClass} pl-10`}
placeholder="Defina a senha"
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
/>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 font-bold text-sm">
Cancelar
</button>
<button type="submit" className="flex-1 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 shadow-lg font-bold text-sm">
Salvar Usuário
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UserManagement;

View File

@ -0,0 +1,25 @@
version: '3.8'
services:
edumanager:
build: .
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_KEY=${VITE_SUPABASE_KEY}
- ASAAS_API_KEY=${ASAAS_API_KEY}
- ASAAS_WEBHOOK_TOKEN=${ASAAS_WEBHOOK_TOKEN}
networks:
- traefik-public
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.edumanager.rule=Host(`edumanager.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edumanager.entrypoints=websecure"
- "traefik.http.routers.edumanager.tls.certresolver=letsencrypt"
- "traefik.http.services.edumanager.loadbalancer.server.port=3000"
networks:
traefik-public:
external: true

46
manager/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EduManager</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
@keyframes slideUpFade {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slideUpFade 0.4s cubic-bezier(0.25, 0.1, 0.25, 1.0) forwards;
}
.animate-slide-down-fade-out {
animation: slideUpFade 0.4s cubic-bezier(0.25, 0.1, 0.25, 1.0) reverse forwards;
}
</style>
<script type="importmap">
{
"imports": {
"recharts": "https://esm.sh/recharts@^3.7.0",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"jspdf": "https://esm.sh/jspdf@^2.5.1",
"jspdf-autotable": "https://esm.sh/jspdf-autotable@^3.8.2",
"@google/genai": "https://esm.sh/@google/genai@^1.40.0",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

282
manager/index.tsx Normal file
View File

@ -0,0 +1,282 @@
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { View, SchoolData, User } from './types';
import { dbService } from './services/dbService';
import Sidebar from './components/Sidebar';
import Dashboard from './components/Dashboard';
import Students from './components/Students';
import Classes from './components/Classes';
import Courses from './components/Courses';
import Finance from './components/Finance';
import Settings from './components/Settings';
import Contracts from './components/Contracts';
import Certificates from './components/Certificates';
import AttendanceCapture from './components/AttendanceCapture';
import AttendanceQuery from './components/AttendanceQuery';
import ReportCard from './components/ReportCard';
import Auth from './components/Auth';
import UserManagement from './components/UserManagement';
import Handouts from './components/Handouts';
import Employees from './components/Employees';
import Messages from './components/Messages';
import AdminNotifications from './components/AdminNotifications';
import Exams from './components/Exams';
import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react';
import { supabase, isSupabaseConfigured } from './services/supabase';
import { DialogProvider } from './DialogContext';
const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [currentView, setCurrentView] = useState<View>(View.Dashboard);
const [deepLinkStudentId, setDeepLinkStudentId] = useState<string | null>(null);
const [deepLinkClassId, setDeepLinkClassId] = useState<string | null>(null);
// Initial load from LocalStorage for speed (fallback), then IDB
const [data, setData] = useState<SchoolData>(dbService.getData());
// Sync Status
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 0. Load from IndexedDB on mount
useEffect(() => {
const loadLocal = async () => {
const localData = await dbService.initData();
setData(prev => ({ ...prev, ...localData }));
};
loadLocal();
}, []);
// 1. Initial Cloud Fetch (Sync on Load)
useEffect(() => {
const initCloud = async () => {
if (isSupabaseConfigured()) {
setSyncStatus('syncing');
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
// If cloud data exists, it takes precedence.
setData(cloudData);
dbService.saveData(cloudData); // Update local cache
setSyncStatus('saved');
} else {
// If no cloud data, we might be starting fresh
setSyncStatus('idle');
}
// Only enable cloud saving AFTER the initial fetch is attempted
setIsCloudEnabled(true);
}
};
initCloud();
}, []);
// 2. Save Data Effect (Local + Debounced Cloud)
useEffect(() => {
// Immediate Local Save
dbService.saveData(data);
// Debounced Cloud Save
if (isCloudEnabled) {
setSyncStatus('syncing');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
try {
const result = await dbService.saveToCloud(data);
if (result.success) {
setSyncStatus('saved');
} else if (result.reason === 'newer_version') {
setSyncStatus('conflict');
} else {
setSyncStatus('error');
}
} catch (e) {
setSyncStatus('error');
}
}, 2000); // Save to cloud 2 seconds after last change
}
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, [data, isCloudEnabled]);
// 3. Dynamic Favicon
useEffect(() => {
const logoUrl = data.logo;
if (logoUrl) {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = logoUrl;
}
}, [data.logo]);
// 4. Efeito para Realtime (Escuta mudanças do Portal em tempo real)
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
useEffect(() => {
if (isCloudEnabled) {
console.log("📡 Iniciando escuta em tempo real para school_data...");
// Cria um canal de escuta para a tabela school_data
const channel = supabase
.channel('school_data_changes')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'school_data', filter: 'id=eq.1' },
(payload) => {
// Quando houver um UPDATE (ex: Portal enviou justificativa)
const newData = payload.new.data as SchoolData;
// Só atualiza se for uma mudança externa (evita loops)
if (newData.lastUpdated !== dataRef.current.lastUpdated) {
console.log("🔔 Nova mudança externa detectada em tempo real!");
setData(newData);
dbService.saveData(newData); // Sincroniza cache local
}
}
)
.subscribe((status) => {
console.log("🔌 Status da conexão Realtime:", status);
});
return () => {
console.log("⚰️ Encerrando canal de Realtime");
supabase.removeChannel(channel);
};
}
}, [isCloudEnabled]);
const updateData = (newData: Partial<SchoolData>) => {
setData(prev => ({
...prev,
...newData,
lastUpdated: new Date().toISOString()
}));
};
const handleUpdateUsers = (newUsers: User[]) => {
updateData({ users: newUsers });
};
const forceSyncFromCloud = async () => {
setSyncStatus('syncing');
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
setData(cloudData);
dbService.saveData(cloudData);
setSyncStatus('saved');
} else {
setSyncStatus('error');
}
};
const handleNavigateToStudent = (studentId: string) => {
setDeepLinkStudentId(studentId);
setCurrentView(View.AttendanceQuery);
};
const handleNavigateToClass = (classId: string, studentId?: string) => {
setDeepLinkClassId(classId);
setDeepLinkStudentId(studentId || null);
setCurrentView(View.Students);
};
const renderView = () => {
switch (currentView) {
case View.Dashboard:
return <Dashboard data={data} />;
case View.Courses:
return <Courses data={data} updateData={updateData} />;
case View.Students:
return <Students data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} deepLinkClassId={deepLinkClassId} clearDeepLink={() => { setDeepLinkStudentId(null); setDeepLinkClassId(null); }} />;
case View.Classes:
return <Classes data={data} updateData={updateData} onNavigateToClass={handleNavigateToClass} />;
case View.Finance:
return <Finance data={data} updateData={updateData} />;
case View.Contracts:
return <Contracts data={data} updateData={updateData} />;
case View.Certificates:
return <Certificates data={data} updateData={updateData} />;
case View.Attendance:
return <AttendanceCapture data={data} updateData={updateData} />;
case View.AttendanceQuery:
return <AttendanceQuery data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} clearDeepLink={() => setDeepLinkStudentId(null)} />;
case View.ReportCard:
return <ReportCard data={data} updateData={updateData} />;
case View.Handouts:
return <Handouts data={data} updateData={updateData} />;
case View.Exams:
return <Exams data={data} updateData={updateData} />;
case View.Employees:
return <Employees data={data} updateData={updateData} />;
case View.Users:
return <UserManagement data={data} updateData={updateData} />;
case View.Messages:
return <Messages data={data} updateData={updateData} />;
case View.Settings:
return <Settings data={data} updateData={updateData} setData={setData} />;
default:
return <Dashboard data={data} />;
}
};
if (!isAuthenticated) {
return <Auth data={data} onLogin={(user) => {
setCurrentUser(user);
setIsAuthenticated(true);
}} onUpdateUsers={handleUpdateUsers} />;
}
return (
<div className="flex min-h-screen bg-slate-50 relative">
<Sidebar currentView={currentView} setView={setCurrentView} user={currentUser} logo={data.logo} />
<main className="flex-1 w-full overflow-y-auto max-h-screen pt-16 md:pt-0 relative">
{/* Sync Indicator - Green Strip on the Right */}
{syncStatus === 'syncing' && (
<div className="fixed top-6 right-0 z-[100] flex flex-col items-end pointer-events-none animate-in slide-in-from-right duration-500">
<div className="bg-emerald-500 text-white py-2.5 px-6 shadow-2xl flex items-center gap-3 border-l-4 border-emerald-300">
<RefreshCw size={16} className="animate-spin" />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Sincronizando</span>
</div>
<div className="w-full h-1 bg-emerald-600/20 relative overflow-hidden">
<div className="absolute inset-0 bg-white/60 animate-pulse"></div>
</div>
</div>
)}
{/* Conflict Alert - Only show when there is a version mismatch */}
{syncStatus === 'conflict' && (
<div className="fixed bottom-6 right-6 z-[100] animate-in slide-in-from-bottom duration-500">
<button
onClick={forceSyncFromCloud}
className="flex items-center gap-3 px-6 py-3 bg-amber-500 text-white rounded-2xl font-black text-xs shadow-2xl hover:bg-amber-600 transition-all active:scale-95 border-2 border-white"
>
<AlertCircle size={18} />
<span>DADOS NOVOS NA NUVEM - CLIQUE PARA ATUALIZAR</span>
</button>
</div>
)}
<div className="max-w-7xl mx-auto p-4 md:p-8">
<AdminNotifications data={data} updateData={updateData} setView={setCurrentView} onNavigateToStudent={handleNavigateToStudent} />
{renderView()}
</div>
</main>
</div>
);
};
const root = createRoot(document.getElementById('root')!);
root.render(
<DialogProvider>
<App />
</DialogProvider>
);

7
manager/metadata.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "Remix: EduManager - Sistema de Gestão Escolar para porteiner",
"description": "A comprehensive school management system for computer courses featuring student registration, class scheduling, financial tracking, contract generation, and employee management.",
"requestFramePermissions": [
"camera"
]
}

555
manager/migrate_to_local.ts Normal file
View File

@ -0,0 +1,555 @@
/**
* ============================================================
* SCRIPT DE MIGRAÇÃO: SUPABASE CLOUD POSTGRESQL LOCAL
* ============================================================
*
* COMO USAR:
* 1. Certifique-se de que o PostgreSQL local está rodando (docker-compose up postgres)
* 2. Instale as dependências: npm install pg @supabase/supabase-js dotenv
* 3. Configure as variáveis de ambiente no arquivo .env.migration
* 4. Execute: npx tsx migrate_to_local.ts
*
* IMPORTANTE:
* - Este script NÃO altera nada no Supabase. Ele apenas .
* - Senhas são copiadas EXATAMENTE como estão, sem rehash.
* - O script usa transações atômicas: se falhar no meio, nada é salvo.
*/
import { createClient } from '@supabase/supabase-js';
import pg from 'pg';
import fs from 'fs';
// ============================================================
// CONFIGURAÇÃO — Altere aqui ou use .env.migration
// ============================================================
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || 'https://ekbuvcjsfcczviqqlfit.supabase.co';
const SUPABASE_KEY = process.env.VITE_SUPABASE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
// ============================================================
// INICIALIZAÇÃO
// ============================================================
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
const pool = new pg.Pool({ connectionString: DATABASE_URL });
function log(emoji: string, msg: string) {
console.log(`${emoji} ${msg}`);
}
function logCount(table: string, count: number) {
log('📦', `${table}: ${count} registro(s) migrado(s)`);
}
// ============================================================
// FUNÇÕES DE MIGRAÇÃO POR ENTIDADE
// ============================================================
async function migrateConfiguracoes(client: pg.PoolClient, schoolData: any) {
const profile = schoolData.profile || {};
const evoConfig = schoolData.evolutionConfig || {};
const msgTemplates = schoolData.messageTemplates || {};
await client.query(`
INSERT INTO configuracoes (id, nome, endereco, cidade, estado, cep, cnpj, telefone, email, tipo, logo, evolution_api_url, evolution_instance_name, evolution_api_key, message_templates)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, endereco = EXCLUDED.endereco, cidade = EXCLUDED.cidade,
estado = EXCLUDED.estado, cep = EXCLUDED.cep, cnpj = EXCLUDED.cnpj,
telefone = EXCLUDED.telefone, email = EXCLUDED.email, tipo = EXCLUDED.tipo,
logo = EXCLUDED.logo, evolution_api_url = EXCLUDED.evolution_api_url,
evolution_instance_name = EXCLUDED.evolution_instance_name,
evolution_api_key = EXCLUDED.evolution_api_key,
message_templates = EXCLUDED.message_templates
`, [
profile.id || 'main-school',
profile.name || 'EduManager School',
profile.address || '',
profile.city || '',
profile.state || '',
profile.zip || '',
profile.cnpj || '',
profile.phone || '',
profile.email || '',
profile.type || 'matriz',
schoolData.logo || '',
evoConfig.apiUrl || null,
evoConfig.instanceName || null,
evoConfig.apiKey || null,
JSON.stringify(msgTemplates)
]);
logCount('configuracoes', 1);
}
async function migrateUsuarios(client: pg.PoolClient, users: any[]) {
for (const u of users) {
await client.query(`
INSERT INTO usuarios (id, username, display_name, photo_url, password, cpf, role)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username, display_name = EXCLUDED.display_name,
password = EXCLUDED.password, cpf = EXCLUDED.cpf, role = EXCLUDED.role
`, [u.id, u.name, u.displayName || null, u.photoURL || null, u.password, u.cpf || '', u.role || 'admin']);
}
logCount('usuarios', users.length);
}
async function migrateCursos(client: pg.PoolClient, courses: any[]) {
for (const c of courses) {
await client.query(`
INSERT INTO cursos (id, nome, duracao, duracao_meses, taxa_matricula, mensalidade, descricao, multa_percentual, juros_percentual)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, duracao = EXCLUDED.duracao, duracao_meses = EXCLUDED.duracao_meses,
taxa_matricula = EXCLUDED.taxa_matricula, mensalidade = EXCLUDED.mensalidade,
descricao = EXCLUDED.descricao
`, [c.id, c.name, c.duration || '', c.durationMonths || 0, c.registrationFee || 0, c.monthlyFee || 0, c.description || '', c.finePercentage || 0, c.interestPercentage || 0]);
}
logCount('cursos', courses.length);
}
async function migrateTurmas(client: pg.PoolClient, classes: any[]) {
for (const c of classes) {
await client.query(`
INSERT INTO turmas (id, nome, curso_id, professor, horario, dia_semana, max_alunos, data_inicio, data_fim, horario_inicio_padrao, horario_fim_padrao)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, curso_id = EXCLUDED.curso_id, professor = EXCLUDED.professor,
horario = EXCLUDED.horario, dia_semana = EXCLUDED.dia_semana,
max_alunos = EXCLUDED.max_alunos
`, [
c.id, c.name, c.courseId || null, c.teacher || '', c.schedule || '',
c.scheduleDay || null, c.maxStudents || 30,
c.startDate || null, c.endDate || null,
c.defaultStartTime || null, c.defaultEndTime || null
]);
}
logCount('turmas', classes.length);
}
async function migrateAlunos(client: pg.PoolClient, students: any[]) {
for (const s of students) {
await client.query(`
INSERT INTO alunos (
id, nome, email, telefone, data_nascimento, cpf, rg, rg_data_emissao,
nome_responsavel, telefone_responsavel, cpf_responsavel, data_nascimento_responsavel,
turma_id, status, motivo_cancelamento, data_matricula, foto_url, face_descriptor,
cep, rua, numero, bairro, cidade, estado,
desconto, tem_responsavel, modelo_contrato_id,
numero_matricula, senha_portal
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15, $16, $17, $18,
$19, $20, $21, $22, $23, $24,
$25, $26, $27,
$28, $29
)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, email = EXCLUDED.email, telefone = EXCLUDED.telefone,
turma_id = EXCLUDED.turma_id, status = EXCLUDED.status,
numero_matricula = EXCLUDED.numero_matricula, senha_portal = EXCLUDED.senha_portal
`, [
s.id, s.name, s.email || '', s.phone || '',
s.birthDate || null, s.cpf || '', s.rg || null, s.rgIssueDate || null,
s.guardianName || null, s.guardianPhone || null, s.guardianCpf || null, s.guardianBirthDate || null,
s.classId || null, s.status || 'active', s.cancellationReason || null,
s.registrationDate || null,
// FOTO: Copia a URL (se já migrou para Storage) ou o base64 temporariamente
s.photo || null,
// FACE DESCRIPTOR: Array de números para reconhecimento facial
s.faceDescriptor ? JSON.stringify(s.faceDescriptor) : null,
s.addressZip || '', s.addressStreet || '', s.addressNumber || '',
s.addressNeighborhood || '', s.addressCity || '', s.addressState || '',
s.discount || 0, s.hasGuardian || false, s.contractTemplateId || null,
// CRÍTICO: Matrícula e Senha copiadas EXATAMENTE como estão
s.enrollmentNumber || null, s.portalPassword || null
]);
}
logCount('alunos', students.length);
}
async function migrateAulas(client: pg.PoolClient, lessons: any[]) {
for (const l of lessons) {
await client.query(`
INSERT INTO aulas (id, turma_id, data, horario_inicio, horario_fim, status, tipo, motivo_cancelamento, aula_original_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status, horario_inicio = EXCLUDED.horario_inicio,
horario_fim = EXCLUDED.horario_fim
`, [
l.id, l.classId, l.date, l.startTime || null, l.endTime || null,
l.status || 'scheduled', l.type || 'regular',
l.cancelReason || null, l.originalLessonId || null
]);
}
logCount('aulas', lessons.length);
}
async function migrateFrequencias(client: pg.PoolClient, attendance: any[]) {
for (const a of attendance) {
await client.query(`
INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
tipo = EXCLUDED.tipo, justificativa = EXCLUDED.justificativa,
justificativa_aceita = EXCLUDED.justificativa_aceita
`, [
a.id, a.studentId, a.classId, a.date,
a.photo || null, a.verified || false,
a.type || 'presence', a.justification || null, a.justificationAccepted ?? null
]);
}
logCount('frequencias', attendance.length);
}
async function migrateDisciplinas(client: pg.PoolClient, subjects: any[]) {
for (const s of subjects) {
await client.query(`
INSERT INTO disciplinas (id, nome, turma_id)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [s.id, s.name, s.classId || null]);
}
logCount('disciplinas', subjects.length);
}
async function migratePeriodos(client: pg.PoolClient, periods: any[]) {
for (const p of periods) {
await client.query(`
INSERT INTO periodos (id, nome)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [p.id, p.name]);
}
logCount('periodos', periods.length);
}
async function migrateNotas(client: pg.PoolClient, grades: any[]) {
for (const g of grades) {
await client.query(`
INSERT INTO notas (id, aluno_id, disciplina_id, valor, periodo)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor
`, [g.id, g.studentId, g.subjectId, g.value || 0, g.period]);
}
logCount('notas', grades.length);
}
async function migratePagamentos(client: pg.PoolClient, payments: any[]) {
for (const p of payments) {
await client.query(`
INSERT INTO pagamentos (
id, aluno_id, contrato_id, valor, desconto, tipo_desconto, multa, juros,
vencimento, status, data_pagamento, tipo, numero_parcela, total_parcelas,
descricao, asaas_payment_id, asaas_payment_url, installment_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, data_pagamento = EXCLUDED.data_pagamento
`, [
p.id, p.studentId, p.contractId || null, p.amount, p.discount || 0,
p.discountType || null, p.lateFee || 0, p.interest || 0,
p.dueDate, p.status || 'pending', p.paidDate || null,
p.type || 'monthly', p.installmentNumber || null, p.totalInstallments || null,
p.description || null, p.asaasPaymentId || null, p.asaasPaymentUrl || null,
p.installmentId || null
]);
}
logCount('pagamentos', payments.length);
}
async function migrateContratos(client: pg.PoolClient, contracts: any[]) {
for (const c of contracts) {
await client.query(`
INSERT INTO contratos (id, aluno_id, titulo, conteudo, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET titulo = EXCLUDED.titulo, conteudo = EXCLUDED.conteudo
`, [c.id, c.studentId, c.title, c.content, c.createdAt || new Date().toISOString()]);
}
logCount('contratos', contracts.length);
}
async function migrateModelosContrato(client: pg.PoolClient, templates: any[]) {
for (const t of templates) {
await client.query(`
INSERT INTO modelos_contrato (id, nome, conteudo)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, conteudo = EXCLUDED.conteudo
`, [t.id, t.name, t.content]);
}
logCount('modelos_contrato', templates.length);
}
async function migrateNotificacoes(client: pg.PoolClient, notifications: any[]) {
for (const n of notifications) {
await client.query(`
INSERT INTO notificacoes (id, aluno_id, titulo, mensagem, lida, anexo, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET lida = EXCLUDED.lida
`, [n.id, n.studentId, n.title, n.message, n.read || false, n.attachment || null, n.createdAt || new Date().toISOString()]);
}
logCount('notificacoes', notifications.length);
}
async function migrateCertificados(client: pg.PoolClient, certificates: any[]) {
for (const c of certificates) {
await client.query(`
INSERT INTO certificados (id, aluno_id, descricao, imagem_frente, imagem_verso, data_emissao, overlays_frente, overlays_verso)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
`, [
c.id, c.studentId, c.description || null, c.frontImage, c.backImage || null,
c.issueDate, JSON.stringify(c.frontOverlays || []), JSON.stringify(c.backOverlays || [])
]);
}
logCount('certificados', certificates.length);
}
async function migrateApostilas(client: pg.PoolClient, handouts: any[]) {
for (const h of handouts) {
await client.query(`
INSERT INTO apostilas (id, nome, preco, descricao, multa_percentual, juros_percentual)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, preco = EXCLUDED.preco
`, [h.id, h.name, h.price || 0, h.description || null, h.finePercentage || 0, h.interestPercentage || 0]);
}
logCount('apostilas', handouts.length);
}
async function migrateEntregasApostilas(client: pg.PoolClient, deliveries: any[]) {
for (const d of deliveries) {
await client.query(`
INSERT INTO entregas_apostilas (id, aluno_id, apostila_id, status_entrega, status_pagamento, data_entrega, data_pagamento, asaas_payment_id, asaas_payment_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING
`, [
d.id, d.studentId, d.handoutId, d.deliveryStatus || 'pending',
d.paymentStatus || 'pending', d.deliveryDate || null, d.paymentDate || null,
d.asaasPaymentId || null, d.asaasPaymentUrl || null
]);
}
logCount('entregas_apostilas', deliveries.length);
}
async function migrateFuncionarios(client: pg.PoolClient, categories: any[], employees: any[]) {
for (const c of categories) {
await client.query(`
INSERT INTO categorias_funcionarios (id, nome)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [c.id, c.name]);
}
logCount('categorias_funcionarios', categories.length);
for (const e of employees) {
await client.query(`
INSERT INTO funcionarios (id, nome, cpf, telefone, email, data_admissao, categoria_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [e.id, e.name, e.cpf || '', e.phone || '', e.email || '', e.admissionDate || null, e.categoryId || null]);
}
logCount('funcionarios', employees.length);
}
async function migrateProvas(client: pg.PoolClient, exams: any[]) {
for (const e of exams) {
await client.query(`
INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET titulo = EXCLUDED.titulo, status = EXCLUDED.status
`, [e.id, e.classId, e.subjectId || null, e.periodId || null, e.title, e.durationMinutes || 60, e.status || 'draft']);
// Migrar questões da prova
const questions = e.questions || [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
await client.query(`
INSERT INTO questoes_provas (id, prova_id, texto, imagem_url, opcoes, indice_correto, ordem)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET texto = EXCLUDED.texto, opcoes = EXCLUDED.opcoes
`, [q.id, e.id, q.text, q.imageUrl || null, JSON.stringify(q.options || []), q.correctOptionIndex || 0, i]);
}
}
logCount('provas + questoes', exams.length);
}
// ============================================================
// MIGRAR TABELAS SEPARADAS DO SUPABASE
// ============================================================
async function migrateCobrancasAsaas(client: pg.PoolClient) {
log('🔄', 'Buscando tabela alunos_cobrancas do Supabase...');
const { data, error } = await supabase
.from('alunos_cobrancas')
.select('*');
if (error) {
log('⚠️', `Erro ao buscar alunos_cobrancas: ${error.message}. Pulando...`);
return;
}
const cobrancas = data || [];
for (const c of cobrancas) {
await client.query(`
INSERT INTO alunos_cobrancas (id, aluno_id, asaas_customer_id, asaas_payment_id, asaas_installment_id, installment, valor, vencimento, status, data_pagamento, link_boleto, link_carne, transaction_receipt_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO NOTHING
`, [
c.id, c.aluno_id, c.asaas_customer_id || null, c.asaas_payment_id || null,
c.asaas_installment_id || null, c.installment || null,
c.valor, c.vencimento, c.status || 'PENDENTE', c.data_pagamento || null,
c.link_boleto || null, c.link_carne || null, c.transaction_receipt_url || null
]);
}
logCount('alunos_cobrancas', cobrancas.length);
}
async function migrateSubmissoesProvas(client: pg.PoolClient) {
log('🔄', 'Buscando tabela provas_submissoes do Supabase...');
const { data, error } = await supabase
.from('provas_submissoes')
.select('*');
if (error) {
log('⚠️', `Erro ao buscar provas_submissoes: ${error.message}. Pulando...`);
return;
}
const subs = data || [];
for (const s of subs) {
await client.query(`
INSERT INTO provas_submissoes (id, aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO NOTHING
`, [
s.id || `sub-${Date.now()}`, s.aluno_id, s.exam_id,
s.total_questions || 0, s.correct_count || 0, s.wrong_count || 0,
s.percentage || 0, s.final_score || 0,
JSON.stringify(s.answers_json || {}), s.created_at || new Date().toISOString()
]);
}
logCount('provas_submissoes', subs.length);
}
// ============================================================
// BACKUP DO JSON COMPLETO (Segurança)
// ============================================================
async function saveJsonBackup(schoolData: any) {
const fileName = `backup_supabase_${new Date().toISOString().split('T')[0]}.json`;
fs.writeFileSync(fileName, JSON.stringify(schoolData, null, 2), 'utf8');
log('💾', `Backup completo salvo em: ${fileName}`);
}
// ============================================================
// FUNÇÃO PRINCIPAL
// ============================================================
async function main() {
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ MIGRAÇÃO EDUMANAGER: SUPABASE → POSTGRESQL LOCAL ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
// 1. Buscar o JSON blob do Supabase
log('🌐', 'Conectando ao Supabase Cloud...');
const { data: schoolRow, error: fetchError } = await supabase
.from('school_data')
.select('data')
.eq('id', 1)
.single();
if (fetchError || !schoolRow?.data) {
log('❌', `FALHA AO CONECTAR AO SUPABASE: ${fetchError?.message || 'Dados não encontrados'}`);
process.exit(1);
}
const schoolData = schoolRow.data;
log('✅', 'Dados baixados do Supabase com sucesso!');
// 2. Salvar backup local primeiro (segurança)
await saveJsonBackup(schoolData);
// 3. Conectar ao PostgreSQL local
log('🔌', 'Conectando ao PostgreSQL local...');
const client = await pool.connect();
try {
// TRANSAÇÃO ATÔMICA: Tudo ou nada
await client.query('BEGIN');
log('🔒', 'Transação iniciada (modo atômico)');
// 4. Também salvar o JSON completo na tabela legada para ponte
log('📋', 'Salvando JSON blob na tabela school_data (ponte)...');
await client.query(`
INSERT INTO school_data (id, data, updated_at)
VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()
`, [JSON.stringify(schoolData)]);
// 5. Migrar entidade por entidade
console.log('');
log('🚀', '═══ INICIANDO MIGRAÇÃO TABELA POR TABELA ═══');
console.log('');
await migrateConfiguracoes(client, schoolData);
await migrateUsuarios(client, schoolData.users || []);
await migrateCursos(client, schoolData.courses || []);
await migrateTurmas(client, schoolData.classes || []);
await migrateAlunos(client, schoolData.students || []);
await migrateAulas(client, schoolData.lessons || []);
await migrateFrequencias(client, schoolData.attendance || []);
await migrateDisciplinas(client, schoolData.subjects || []);
await migratePeriodos(client, schoolData.periods || []);
await migrateNotas(client, schoolData.grades || []);
await migratePagamentos(client, schoolData.payments || []);
await migrateContratos(client, schoolData.contracts || []);
await migrateModelosContrato(client, schoolData.contractTemplates || []);
await migrateNotificacoes(client, schoolData.notifications || []);
await migrateCertificados(client, schoolData.certificates || []);
await migrateApostilas(client, schoolData.handouts || []);
await migrateEntregasApostilas(client, schoolData.handoutDeliveries || []);
await migrateFuncionarios(client, schoolData.employeeCategories || [], schoolData.employees || []);
await migrateProvas(client, schoolData.exams || []);
// 6. Migrar tabelas separadas do Supabase
console.log('');
log('🔄', '═══ MIGRANDO TABELAS SEPARADAS ═══');
console.log('');
await migrateCobrancasAsaas(client);
await migrateSubmissoesProvas(client);
// 7. Commit da transação
await client.query('COMMIT');
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ ✅ MIGRAÇÃO CONCLUÍDA COM SUCESSO! ║');
console.log('║ ║');
console.log('║ • Todos os dados foram copiados com integridade ║');
console.log('║ • Senhas mantidas EXATAMENTE como estavam ║');
console.log('║ • Backup JSON salvo localmente ║');
console.log('║ • Tabela school_data (legada) populada como ponte ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
} catch (error: any) {
// ROLLBACK: Se qualquer coisa falhar, NADA é salvo
await client.query('ROLLBACK');
console.log('');
log('❌', '══════════════════════════════════════════════════');
log('❌', `ERRO NA MIGRAÇÃO: ${error.message}`);
log('❌', 'ROLLBACK executado. Nenhum dado foi alterado no PostgreSQL.');
log('❌', '══════════════════════════════════════════════════');
console.log('');
console.error(error);
} finally {
client.release();
await pool.end();
}
}
// Execução
main().catch(console.error);

5558
manager/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
manager/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "edumanager---sistema-de-gestão-escolar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx server.js",
"build": "vite build",
"start": "node server.js",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@google/genai": "^1.40.0",
"@supabase/supabase-js": "2.39.0",
"@vladmandic/face-api": "^1.7.15",
"browser-image-compression": "^2.0.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.563.0",
"multer": "^2.1.1",
"pg": "^8.13.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
"sharp": "^0.34.5",
"tsx": "^4.21.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.4.1"
}
}

View File

@ -0,0 +1,52 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = 'https://ekbuvcjsfcczviqqlfit.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us';
const supabase = createClient(supabaseUrl, supabaseKey);
async function checkDatabase() {
const results = {
tables: {},
storage: {},
errors: []
};
// 1. Check school_data table
try {
const { data: schoolData, error: schoolError } = await supabase.from('school_data').select('*').limit(1);
results.tables.school_data = { exists: !schoolError, error: schoolError?.message };
if (schoolData?.[0]?.data) {
results.tables.school_data.hasData = true;
results.tables.school_data.hasExams = Array.isArray(schoolData[0].data.exams);
}
} catch (e) { results.errors.push('school_data check failed: ' + e.message); }
// 2. Check provas_submissoes table
try {
const { data: subData, error: subError } = await supabase.from('provas_submissoes').select('*').limit(1);
results.tables.provas_submissoes = { exists: !subError, error: subError?.message };
} catch (e) { results.errors.push('provas_submissoes check failed: ' + e.message); }
// 3. Check alunos_cobrancas table
try {
const { data: cobData, error: cobError } = await supabase.from('alunos_cobrancas').select('*').limit(1);
results.tables.alunos_cobrancas = { exists: !cobError, error: cobError?.message };
} catch (e) { results.errors.push('alunos_cobrancas check failed: ' + e.message); }
// 4. Check edumanager-assets storage
try {
const { data: buckets, error: bucketError } = await supabase.storage.listBuckets();
if (bucketError) {
results.storage.error = bucketError.message;
} else {
const bucket = buckets.find(b => b.id === 'edumanager-assets');
results.storage.edumanagerAssets = { exists: !!bucket, public: bucket?.public };
}
} catch (e) { results.errors.push('storage check failed: ' + e.message); }
console.log(JSON.stringify(results, null, 2));
}
checkDatabase();

725
manager/server.js Normal file
View File

@ -0,0 +1,725 @@
/**
* ============================================================
* EDUMANAGER SERVER SELF-HOSTED
* ============================================================
* SUBSTITUIÇÃO CIRÚRGICA:
* - @supabase/supabase-js pg (PostgreSQL direto)
* - Supabase Storage MinIO (S3-compatible)
*
* TODAS AS ROTAS mantêm a mesma assinatura e resposta.
* O frontend NÃO percebe a diferença.
* ============================================================
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import multer from 'multer';
import sharp from 'sharp';
import jwt from 'jsonwebtoken';
// === Novos módulos Self-Hosted (substituem Supabase) ===
import {
getSchoolData, saveSchoolData, pool,
insertCobrancas, updateCobranca, deleteCobranca,
getCobrancaByPaymentId, getCobrancasByOrQuery,
getCobrancasByAlunoId, getCobrancasAtrasadas,
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage } from './services/storage.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!';
// === ASAAS: URL base dinâmica inteligente ===
const ASAAS_KEY = process.env.ASAAS_API_KEY || '';
const ASAAS_BASE_URL = process.env.ASAAS_API_URL || (ASAAS_KEY.startsWith('$a') ? 'https://api.asaas.com' : 'https://sandbox.asaas.com/api');
app.use(express.json({ limit: '50mb' }));
app.use(cors());
const cancelCache = new Set();
const sentCache = new Set();
const lockCache = new Set();
const upload = multer({ storage: multer.memoryStorage() });
// ============================================================
// ROTA NOVA: Login Administrativo (JWT)
// ============================================================
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuário e senha são obrigatórios' });
}
const { rows } = await pool.query(
'SELECT * FROM usuarios WHERE username = $1',
[username]
);
const user = rows[0];
if (!user || user.password !== password) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, name: user.display_name || user.username, role: user.role } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTA NOVA: API para o dbService.ts do Frontend
// GET /api/school-data → fetchFromCloud()
// PUT /api/school-data → saveToCloud()
// ============================================================
app.get('/api/school-data', async (req, res) => {
try {
const data = await getSchoolData();
res.json({ data });
} catch (error) {
console.error('Erro ao buscar school_data:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/school-data', async (req, res) => {
try {
const schoolData = req.body;
if (!schoolData) return res.status(400).json({ error: 'Dados não fornecidos' });
// Verificação de timestamp para evitar regressão
const current = await getSchoolData();
const cloudTimestamp = current.lastUpdated ? new Date(current.lastUpdated).getTime() : 0;
const localTimestamp = schoolData.lastUpdated ? new Date(schoolData.lastUpdated).getTime() : 0;
if (cloudTimestamp > localTimestamp) {
return res.status(409).json({ success: false, reason: 'newer_version' });
}
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao salvar school_data:', error);
res.status(500).json({ success: false, reason: 'error' });
}
});
// ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================
app.post('/api/upload/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const compressedBuffer = await sharp(req.file.buffer)
.resize(500, 500, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 60 })
.toBuffer();
const url = await uploadLogoToStorage(compressedBuffer, 'image/webp');
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar logo:', error);
return res.status(500).json({ error: 'Erro interno ao processar a imagem.' });
}
});
// ============================================================
// Upload de Foto de Aluno (MinIO)
// ============================================================
app.post('/api/upload/student-photo', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadStudentPhoto } = await import('./services/storage.js');
const url = await uploadStudentPhoto(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar foto:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Formatação de Data
// ============================================================
function formatCobrancaDate(dateStr) {
if (!dateStr) return '';
const [Ano, Mes, Dia] = dateStr.split('-');
if (!Dia) return dateStr;
return `${Dia}/${Mes}/${Ano}`;
}
// ============================================================
// Integração WhatsApp Evolution API
// (Mesma lógica, trocando supabase por database.js)
// ============================================================
async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload = null) {
try {
let cob = null;
for (let i = 0; i < 3; i++) {
cob = await getCobrancaByPaymentId(asaasPaymentId);
if (cob) break;
if (i < 2) await new Promise(r => setTimeout(r, 1000));
}
if (!cob) return console.log(`[Evolution] Cobrança não encontrada: ${asaasPaymentId}`);
let fallbackValor = cob.valor;
let fallbackVencimento = cob.vencimento;
let fallbackDescricao = paymentPayload?.description || 'serviços educacionais';
const appData = await getSchoolData();
if (!appData) return console.log('[WhatsApp] school_data não encontrado');
const evoConfig = appData.evolutionConfig;
const templates = appData.messageTemplates;
if (!evoConfig || !evoConfig.apiUrl || !evoConfig.apiKey || !evoConfig.instanceName) {
return console.log('[WhatsApp] Credenciais Evolution não configuradas.');
}
const normalizedEvent = (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') ? 'PAYMENT_RECEIVED' : eventType;
const cacheKey = `${asaasPaymentId}_${normalizedEvent}`;
if (sentCache.has(cacheKey)) return;
sentCache.add(cacheKey);
setTimeout(() => sentCache.delete(cacheKey), 30000);
const aluno = appData.students?.find(s => s.id === cob.aluno_id);
if (!aluno) return console.log('[WhatsApp] Aluno não encontrado.');
const birthDateStr = aluno.data_nascimento || aluno.birthDate || '';
let age = 18;
if (birthDateStr && birthDateStr.includes('-')) {
const parts = birthDateStr.split('T')[0].split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
const birthDate = new Date(year, month - 1, day);
const today = new Date();
age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;
}
const isMinor = age < 18;
const targetPhone = (isMinor && (aluno.telefone_responsavel || aluno.guardianPhone)) ? (aluno.telefone_responsavel || aluno.guardianPhone) : (aluno.telefone || aluno.phone);
const targetName = (isMinor && (aluno.nome_responsavel || aluno.guardianName)) ? (aluno.nome_responsavel || aluno.guardianName) : (aluno.nome || aluno.name);
if (!targetPhone) return console.log('[WhatsApp] Sem telefone.');
let cleanPhone = targetPhone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
let descricao = fallbackDescricao;
let pdfUrl = cob.link_carne || cob.link_boleto || '';
let isCarneCompleto = false;
const pResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${asaasPaymentId}`, {
headers: { 'access_token': process.env.ASAAS_API_KEY }
});
if (pResp.ok) {
const pData = await pResp.json();
if (pData.description) descricao = pData.description;
if (pData.value) fallbackValor = pData.value;
if (pData.dueDate) fallbackVencimento = pData.dueDate;
if (descricao.includes('Parcela')) {
if (eventType === 'PAYMENT_CREATED') descricao = descricao.replace(' de ', ' a ');
else if (['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED', 'PAYMENT_UPDATED'].includes(eventType)) {
descricao = descricao.replace(/Parcela (\d+) a (\d+)/g, 'Parcela $1 de $2');
}
}
if (pData.installment && eventType === 'PAYMENT_CREATED') {
if (pData.installmentNumber > 1) return;
isCarneCompleto = true;
pdfUrl = `${ASAAS_BASE_URL}/v3/installments/${pData.installment}/paymentBook`;
} else {
pdfUrl = pData.transactionReceiptUrl || pData.bankSlipUrl || pData.invoiceUrl || pdfUrl;
}
}
const fbGerado = 'Olá {nome}, sua cobrança referente a {descricao} no valor de R$ {valor} foi gerada. Vencimento: {vencimento}.';
const fbPago = 'Olá {nome}, confirmamos o pagamento de R$ {valor} referente a {descricao}. Muito obrigado!';
const fbAtrasado = 'Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:';
const fbCancelado = 'Olá {nome}, a cobrança referente a {descricao} foi cancelada.';
const fbAtualizado = 'Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:';
let templateText = '';
if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado;
else if (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') templateText = templates?.pagamentoConfirmado || fbPago;
else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado;
else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado;
else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado;
if (!templateText) return;
let msgFinal = templateText
.replace(/{nome}/g, targetName)
.replace(/{nome_aluno}/g, aluno.name)
.replace(/{matricula}/g, aluno.enrollmentNumber || aluno.matricula || '—')
.replace(/{valor}/g, parseFloat(fallbackValor).toFixed(2).replace('.', ','))
.replace(/{vencimento}/g, formatCobrancaDate(typeof fallbackVencimento === 'string' ? fallbackVencimento : ''))
.replace(/{link_boleto}/g, pdfUrl)
.replace(/{descricao}/g, descricao);
const isTextOnlyEvent = ['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED', 'PAYMENT_DELETED'].includes(eventType);
const isPaymentConfirmation = ['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED'].includes(eventType);
const isCreationEvent = eventType === 'PAYMENT_CREATED';
if (isPaymentConfirmation && pdfUrl && !templateText.includes('{link_boleto}')) {
msgFinal += `\n\n📄 Acesse seu comprovante aqui:\n${pdfUrl}`;
}
let base64Pdf = null;
if (pdfUrl && !isTextOnlyEvent) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const fetchOptions = { headers: { 'Accept': 'application/pdf' } };
if (pdfUrl.includes('asaas.com')) fetchOptions.headers['access_token'] = process.env.ASAAS_API_KEY;
const pdfResp = await fetch(pdfUrl, fetchOptions);
if (pdfResp.ok && pdfResp.headers.get('content-type')?.includes('pdf')) {
const arrayBuffer = await pdfResp.arrayBuffer();
base64Pdf = Buffer.from(arrayBuffer).toString('base64');
break;
}
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
} catch (err) {
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
}
}
}
if ((isCreationEvent || isPaymentConfirmation || eventType === 'PAYMENT_UPDATED') && !base64Pdf && pdfUrl) {
msgFinal += `\n\n📄 Acesse aqui sua cobrança:\n${pdfUrl}`;
}
let endpoint = 'sendText';
let payload = {};
if (base64Pdf) {
endpoint = 'sendMedia';
let fileName = `Boleto-${targetName.replace(/\s+/g, '')}.pdf`;
if (isCarneCompleto) fileName = `Carne-${targetName.replace(/\s+/g, '')}.pdf`;
if (isPaymentConfirmation) fileName = `Comprovante-${targetName.replace(/\s+/g, '')}.pdf`;
payload = { number: cleanPhone, options: { delay: 1200, presence: "composing" }, mediatype: "document", mimetype: "application/pdf", fileName, media: base64Pdf, caption: msgFinal };
} else {
payload = { number: cleanPhone, text: msgFinal };
}
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/${endpoint}/${evoConfig.instanceName}`;
const sendResp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify(payload) });
if (sendResp.ok) console.log(`[WhatsApp] ✅ Enviado para ${cleanPhone}`);
else console.error(`[WhatsApp] ❌ Erro:`, sendResp.status);
} catch (error) {
console.error('[WhatsApp] Erro interno:', error.message);
}
}
// ============================================================
// Webhook Asaas (Substituídas chamadas supabase por database.js)
// ============================================================
app.post('/api/webhook_asaas', async (req, res) => {
const tokenRecebido = req.headers['asaas-access-token'];
if (tokenRecebido !== process.env.ASAAS_WEBHOOK_TOKEN) {
addLog('Webhook', 'Auth Negada', 'Token inválido');
return res.status(401).json({ error: 'Não autorizado' });
}
try {
const payload = req.body;
if (payload.dateCreated) {
const diffHours = (Date.now() - new Date(payload.dateCreated).getTime()) / (1000 * 60 * 60);
if (diffHours > 24) return res.status(200).send('OK');
}
const asaasPaymentId = payload.payment.id;
let updateData = {};
switch (payload.event) {
case 'PAYMENT_CREATED':
setTimeout(() => sendEvolutionMessage(asaasPaymentId, 'PAYMENT_CREATED'), 2000);
return res.status(200).json({ message: 'OK' });
case 'PAYMENT_RECEIVED':
case 'PAYMENT_CONFIRMED':
updateData = {
status: 'PAGO',
valor: payload.payment.value,
data_pagamento: payload.payment.confirmedDate || payload.payment.paymentDate || new Date().toISOString().split('T')[0]
};
if (payload.payment.transactionReceiptUrl) {
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
}
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
break;
case 'PAYMENT_OVERDUE':
case 'PAYMENT_UPDATED':
case 'PAYMENT_RESTORED':
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' };
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined };
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]);
if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
else if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break;
case 'PAYMENT_DELETED':
case 'PAYMENT_CANCELED':
const installmentId = payload.payment.installment;
if (installmentId) {
if (cancelCache.has(installmentId)) {
await deleteCobranca(asaasPaymentId);
return res.status(200).send('OK');
}
cancelCache.add(installmentId);
setTimeout(() => cancelCache.delete(installmentId), 60000);
}
await sendEvolutionMessage(asaasPaymentId, 'PAYMENT_DELETED');
await deleteCobranca(asaasPaymentId);
addLog('Webhook', 'PAYMENT_DELETED', { asaasPaymentId });
return res.status(200).send('OK');
default:
return res.status(200).json({ message: 'Evento ignorado' });
}
await updateCobranca(asaasPaymentId, updateData);
addLog('Webhook', `Sucesso ${payload.event}`, { asaasPaymentId });
return res.status(200).json({ message: 'OK' });
} catch (error) {
console.error('Webhook erro:', error);
return res.status(500).json({ error: 'Erro interno' });
}
});
// Webhook Evolution
app.post('/api/webhooks/evolution', (req, res) => {
try {
const payload = req.body;
let messageData = payload.data || payload;
if (messageData.status === 'READ') {
const phone = messageData.key?.remoteJid || 'Desconhecido';
console.log(`👀 [WhatsApp STATUS] Mensagem LIDA: ${phone.split('@')[0]}`);
}
res.status(200).send('OK');
} catch (err) {
res.status(500).send('Erro');
}
});
// ============================================================
// Gerar Cobrança
// ============================================================
app.post('/api/gerar_cobranca', async (req, res) => {
try {
const { aluno_id, nome, cpf, email, valor, vencimento, multa, juros, desconto, telefone, cep, endereco, numero, bairro, descricao, parcelas, nascimento } = req.body;
let customerId = '';
const searchRes = await fetch(`${ASAAS_BASE_URL}/v3/customers?cpfCnpj=${cpf}`, { method: 'GET', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.data?.length > 0) customerId = searchData.data[0].id;
}
if (!customerId) {
const customerRes = await fetch(`${ASAAS_BASE_URL}/v3/customers`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ name: nome, cpfCnpj: cpf, email, mobilePhone: telefone, postalCode: cep, address: endereco, addressNumber: numero, province: bairro, birthDate: nascimento })
});
if (!customerRes.ok) {
const errorData = await customerRes.json();
throw new Error(errorData.errors?.[0]?.description || 'Falha ao criar cliente');
}
customerId = (await customerRes.json()).id;
}
const asaasPayload = { customer: customerId, billingType: 'BOLETO', dueDate: vencimento, description: descricao ? `${descricao} - Microtec Informática Cursos` : 'Mensalidade - Microtec Informática Cursos' };
const isInstallment = parcelas && parseInt(parcelas) > 1;
if (isInstallment) { asaasPayload.installmentCount = parseInt(parcelas); asaasPayload.installmentValue = parseFloat(valor); }
else { asaasPayload.value = parseFloat(valor); }
const fineValue = parseFloat(multa); const interestValue = parseFloat(juros); const discountValue = parseFloat(desconto);
if (!isNaN(fineValue) && fineValue > 0) asaasPayload.fine = { value: fineValue, type: 'PERCENTAGE' };
if (!isNaN(interestValue) && interestValue > 0) asaasPayload.interest = { value: interestValue, type: 'PERCENTAGE' };
if (!isNaN(discountValue) && discountValue > 0) asaasPayload.discount = { value: discountValue, dueDateLimitDays: 0, type: 'FIXED' };
const paymentRes = await fetch(`${ASAAS_BASE_URL}/v3/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY }, body: JSON.stringify(asaasPayload) });
if (!paymentRes.ok) { const e = await paymentRes.json(); throw new Error(e.errors?.[0]?.description || 'Falha Asaas'); }
const paymentData = await paymentRes.json();
let paymentsToSave = [];
const instId = formatInstallmentId(paymentData.installment);
if (isInstallment && instId) {
const installmentsRes = await fetch(`${ASAAS_BASE_URL}/v3/payments?installment=${instId}&limit=100`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (installmentsRes.ok) {
const installmentsData = await installmentsRes.json();
paymentsToSave = installmentsData.data.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)).map(p => ({
aluno_id, asaas_customer_id: customerId, asaas_payment_id: p.id, asaas_installment_id: instId, installment: instId, valor: p.value, vencimento: p.dueDate, link_boleto: p.bankSlipUrl
}));
} else throw new Error('Falha ao buscar parcelas');
} else {
paymentsToSave = [{ aluno_id, asaas_customer_id: customerId, asaas_payment_id: paymentData.id, installment: null, valor: paymentData.value || valor, vencimento: paymentData.dueDate || vencimento, link_boleto: paymentData.bankSlipUrl }];
}
await insertCobrancas(paymentsToSave);
if (paymentsToSave.length > 0) {
sendEvolutionMessage(paymentsToSave[0].asaas_payment_id, 'PAYMENT_CREATED').catch(e => console.error('Erro disparo:', e));
}
return res.status(200).json({ success: true, installment: instId || null, payments: paymentsToSave, bankSlipUrl: paymentsToSave[0]?.link_boleto, paymentId: paymentsToSave[0]?.asaas_payment_id });
} catch (error) {
console.error('Erro gerar cobrança:', error);
return res.status(500).json({ error: error.message });
}
});
// ============================================================
// Disparo em Massa
// ============================================================
app.post('/api/enviar-massa', (req, res) => {
const { alunos, mensagem } = req.body;
if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' });
res.status(200).json({ success: true, message: 'Background iniciado.' });
processarFilaWhatsApp(alunos, mensagem);
});
async function processarFilaWhatsApp(alunos, mensagemTemplate) {
const appData = await getSchoolData();
const evoConfig = appData?.evolutionConfig;
if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return;
for (let i = 0; i < alunos.length; i++) {
const aluno = alunos[i];
const msg = mensagemTemplate.replace(/{nome}/g, aluno.nome).replace(/{matricula}/g, aluno.matricula || '—');
try {
let cleanPhone = aluno.telefone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
} catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); }
if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 120000) + 60000));
}
}
// ============================================================
// Logs
// ============================================================
const apiLogs = [];
function addLog(service, action, details) {
apiLogs.unshift({ date: new Date().toISOString(), service, action, details });
if (apiLogs.length > 200) apiLogs.pop();
}
app.get('/api/logs', (req, res) => res.json(apiLogs));
const isUUID = (str) => typeof str === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
const formatInstallmentId = (id) => { if (!id) return id; if (id.startsWith('inst_')) return id.replace('inst_', 'ins_'); return id; };
// ============================================================
// Exclusão de Cobrança
// ============================================================
app.post('/api/excluir_cobranca', async (req, res) => {
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID não fornecido' });
const parcelas = await getCobrancasByOrQuery(id);
let isSinglePayment = id.startsWith('pay_');
if (!isSinglePayment) {
const asaasTargetId = formatInstallmentId(id);
const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (resp.ok) addLog('Asaas', 'Exclusão Parcelamento OK', { id });
} else {
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
}
return res.status(200).json({ message: 'Excluído no Asaas (Aguardando Webhook)' });
} catch (error) {
console.error('[Exclusão] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Carnês e Links
// ============================================================
app.get('/api/parcelamentos/:id/carne', async (req, res) => {
try {
const id = req.params.id;
const parcelas = await getCobrancasByOrQuery(id);
let instId = (!id.startsWith('pay_')) ? id : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
if (instId) {
const asaasTargetInstId = formatInstallmentId(instId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.status(200).json({ status: 'success', type: 'pdf', url: pSaved.link_carne });
const ar = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (ar.ok && ar.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await ar.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(instId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
}
const boletos = parcelas ? parcelas.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id })) : [];
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.get('/api/cobrancas/:id/link', async (req, res) => {
try {
const p = await fetch(`${ASAAS_BASE_URL}/v3/payments/${req.params.id}`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!p.ok) return res.status(404).json({ error: 'Não encontrada.' });
const d = await p.json();
return res.status(200).json({ bankSlipUrl: d.bankSlipUrl || d.invoiceUrl, transactionReceiptUrl: d.transactionReceiptUrl });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.patch('/api/alunos/:id/rematricular', async (req, res) => res.json({ success: true }));
app.put('/api/cobrancas/:id', async (req, res) => {
try {
const { id } = req.params;
const { valor, vencimento } = req.body;
let targetAsaasId = id;
if (isUUID(id)) {
const parcelas = await getCobrancasByOrQuery(id);
if (parcelas.length > 0 && parcelas[0].asaas_payment_id) targetAsaasId = parcelas[0].asaas_payment_id;
}
const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ value: valor, dueDate: vencimento })
});
if (!aResp.ok) { const err = await aResp.json().catch(() => ({})); return res.status(400).json({ error: err.errors?.[0]?.description || 'Erro Asaas' }); }
const queryField = isUUID(id) ? 'id' : 'asaas_payment_id';
await updateCobrancaByField(queryField, id, { valor, vencimento });
res.json({ message: 'Editado com sucesso' });
} catch (e) { res.status(500).json({ error: 'Erro interno.' }); }
});
app.get('/api/alunos/:id/carne', async (req, res) => {
try {
const cobrancas = await getCobrancasByAlunoId(req.params.id);
const withInstallment = cobrancas.filter(c => c.asaas_installment_id);
if (withInstallment.length === 0) return res.status(404).json({ error: 'Nenhum carnê.' });
const latestInstId = withInstallment[withInstallment.length - 1].asaas_installment_id;
const asaasTargetInstId = formatInstallmentId(latestInstId);
const binResp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (binResp.ok && binResp.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await binResp.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(latestInstId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
const allCobs = await getCobrancasByInstallmentId(latestInstId);
const boletos = allCobs.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id }));
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// ============================================================
// INICIALIZAÇÃO
// ============================================================
async function startServer() {
const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.use((req, res, next) => req.path.startsWith('/api') ? next() : res.sendFile(path.join(distPath, 'index.html')));
} else {
const vite = await import('vite').then(m => m.createServer({ server: { middlewareMode: true }, appType: 'spa' }));
app.use(vite.middlewares);
}
// Disparo Manual de Inadimplência
app.post('/api/disparar_cobrancas', async (req, res) => {
try {
const atrasados = await getCobrancasAtrasadas();
if (atrasados.length === 0) return res.status(200).json({ message: 'Nenhuma atrasada.' });
let enviadas = 0;
for (const cob of atrasados) {
if (cob.asaas_payment_id) { await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); enviadas++; }
}
return res.status(200).json({ message: `${enviadas} mensagens processadas.` });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// Imprimir Carnê
app.get('/api/imprimir-carne/:installmentId', async (req, res) => {
try {
const { installmentId } = req.params;
const parcelas = await getCobrancasByOrQuery(installmentId);
let instId = (!installmentId.startsWith('pay_')) ? installmentId : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
const asaasTargetInstId = formatInstallmentId(instId || installmentId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.redirect(pSaved.link_carne);
let asaasUrl = `${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`;
const { sort, order } = req.query;
const params = new URLSearchParams();
if (sort) params.append('sort', sort);
if (order) params.append('order', order);
if (params.toString()) asaasUrl += `?${params.toString()}`;
const response = await fetch(asaasUrl, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (response.ok && response.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await response.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
// Upload assíncrono para MinIO
uploadCarneToStorage(fileName, buffer).then(publicUrl => {
updateCobrancaLinkCarne(instId, publicUrl).catch(() => {});
}).catch(() => {});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="carne.pdf"');
return res.send(buffer);
} else {
return res.status(response.status).send('Falha Asaas');
}
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.listen(PORT, '0.0.0.0', () => console.log(`🚀 EduManager Self-Hosted na porta ${PORT}`));
}
startServer();

View File

@ -0,0 +1,725 @@
/**
* ============================================================
* EDUMANAGER SERVER SELF-HOSTED
* ============================================================
* SUBSTITUIÇÃO CIRÚRGICA:
* - @supabase/supabase-js pg (PostgreSQL direto)
* - Supabase Storage MinIO (S3-compatible)
*
* TODAS AS ROTAS mantêm a mesma assinatura e resposta.
* O frontend NÃO percebe a diferença.
* ============================================================
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import multer from 'multer';
import sharp from 'sharp';
import jwt from 'jsonwebtoken';
// === Novos módulos Self-Hosted (substituem Supabase) ===
import {
getSchoolData, saveSchoolData, pool,
insertCobrancas, updateCobranca, deleteCobranca,
getCobrancaByPaymentId, getCobrancasByOrQuery,
getCobrancasByAlunoId, getCobrancasAtrasadas,
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage } from './services/storage.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!';
// === ASAAS: URL base dinâmica inteligente ===
const ASAAS_KEY = process.env.ASAAS_API_KEY || '';
const ASAAS_BASE_URL = process.env.ASAAS_API_URL || (ASAAS_KEY.startsWith('$a') ? 'https://api.asaas.com' : 'https://sandbox.asaas.com/api');
app.use(express.json({ limit: '50mb' }));
app.use(cors());
const cancelCache = new Set();
const sentCache = new Set();
const lockCache = new Set();
const upload = multer({ storage: multer.memoryStorage() });
// ============================================================
// ROTA NOVA: Login Administrativo (JWT)
// ============================================================
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuário e senha são obrigatórios' });
}
const { rows } = await pool.query(
'SELECT * FROM usuarios WHERE username = $1',
[username]
);
const user = rows[0];
if (!user || user.password !== password) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, name: user.display_name || user.username, role: user.role } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// ROTA NOVA: API para o dbService.ts do Frontend
// GET /api/school-data → fetchFromCloud()
// PUT /api/school-data → saveToCloud()
// ============================================================
app.get('/api/school-data', async (req, res) => {
try {
const data = await getSchoolData();
res.json({ data });
} catch (error) {
console.error('Erro ao buscar school_data:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/school-data', async (req, res) => {
try {
const schoolData = req.body;
if (!schoolData) return res.status(400).json({ error: 'Dados não fornecidos' });
// Verificação de timestamp para evitar regressão
const current = await getSchoolData();
const cloudTimestamp = current.lastUpdated ? new Date(current.lastUpdated).getTime() : 0;
const localTimestamp = schoolData.lastUpdated ? new Date(schoolData.lastUpdated).getTime() : 0;
if (cloudTimestamp > localTimestamp) {
return res.status(409).json({ success: false, reason: 'newer_version' });
}
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao salvar school_data:', error);
res.status(500).json({ success: false, reason: 'error' });
}
});
// ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================
app.post('/api/upload/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const compressedBuffer = await sharp(req.file.buffer)
.resize(500, 500, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 60 })
.toBuffer();
const url = await uploadLogoToStorage(compressedBuffer, 'image/webp');
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar logo:', error);
return res.status(500).json({ error: 'Erro interno ao processar a imagem.' });
}
});
// ============================================================
// Upload de Foto de Aluno (MinIO)
// ============================================================
app.post('/api/upload/student-photo', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadStudentPhoto } = await import('./services/storage.js');
const url = await uploadStudentPhoto(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar foto:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Formatação de Data
// ============================================================
function formatCobrancaDate(dateStr) {
if (!dateStr) return '';
const [Ano, Mes, Dia] = dateStr.split('-');
if (!Dia) return dateStr;
return `${Dia}/${Mes}/${Ano}`;
}
// ============================================================
// Integração WhatsApp Evolution API
// (Mesma lógica, trocando supabase por database.js)
// ============================================================
async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload = null) {
try {
let cob = null;
for (let i = 0; i < 3; i++) {
cob = await getCobrancaByPaymentId(asaasPaymentId);
if (cob) break;
if (i < 2) await new Promise(r => setTimeout(r, 1000));
}
if (!cob) return console.log(`[Evolution] Cobrança não encontrada: ${asaasPaymentId}`);
let fallbackValor = cob.valor;
let fallbackVencimento = cob.vencimento;
let fallbackDescricao = paymentPayload?.description || 'serviços educacionais';
const appData = await getSchoolData();
if (!appData) return console.log('[WhatsApp] school_data não encontrado');
const evoConfig = appData.evolutionConfig;
const templates = appData.messageTemplates;
if (!evoConfig || !evoConfig.apiUrl || !evoConfig.apiKey || !evoConfig.instanceName) {
return console.log('[WhatsApp] Credenciais Evolution não configuradas.');
}
const normalizedEvent = (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') ? 'PAYMENT_RECEIVED' : eventType;
const cacheKey = `${asaasPaymentId}_${normalizedEvent}`;
if (sentCache.has(cacheKey)) return;
sentCache.add(cacheKey);
setTimeout(() => sentCache.delete(cacheKey), 30000);
const aluno = appData.students?.find(s => s.id === cob.aluno_id);
if (!aluno) return console.log('[WhatsApp] Aluno não encontrado.');
const birthDateStr = aluno.data_nascimento || aluno.birthDate || '';
let age = 18;
if (birthDateStr && birthDateStr.includes('-')) {
const parts = birthDateStr.split('T')[0].split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
const birthDate = new Date(year, month - 1, day);
const today = new Date();
age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;
}
const isMinor = age < 18;
const targetPhone = (isMinor && (aluno.telefone_responsavel || aluno.guardianPhone)) ? (aluno.telefone_responsavel || aluno.guardianPhone) : (aluno.telefone || aluno.phone);
const targetName = (isMinor && (aluno.nome_responsavel || aluno.guardianName)) ? (aluno.nome_responsavel || aluno.guardianName) : (aluno.nome || aluno.name);
if (!targetPhone) return console.log('[WhatsApp] Sem telefone.');
let cleanPhone = targetPhone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
let descricao = fallbackDescricao;
let pdfUrl = cob.link_carne || cob.link_boleto || '';
let isCarneCompleto = false;
const pResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${asaasPaymentId}`, {
headers: { 'access_token': process.env.ASAAS_API_KEY }
});
if (pResp.ok) {
const pData = await pResp.json();
if (pData.description) descricao = pData.description;
if (pData.value) fallbackValor = pData.value;
if (pData.dueDate) fallbackVencimento = pData.dueDate;
if (descricao.includes('Parcela')) {
if (eventType === 'PAYMENT_CREATED') descricao = descricao.replace(' de ', ' a ');
else if (['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED', 'PAYMENT_UPDATED'].includes(eventType)) {
descricao = descricao.replace(/Parcela (\d+) a (\d+)/g, 'Parcela $1 de $2');
}
}
if (pData.installment && eventType === 'PAYMENT_CREATED') {
if (pData.installmentNumber > 1) return;
isCarneCompleto = true;
pdfUrl = `${ASAAS_BASE_URL}/v3/installments/${pData.installment}/paymentBook`;
} else {
pdfUrl = pData.transactionReceiptUrl || pData.bankSlipUrl || pData.invoiceUrl || pdfUrl;
}
}
const fbGerado = 'Olá {nome}, sua cobrança referente a {descricao} no valor de R$ {valor} foi gerada. Vencimento: {vencimento}.';
const fbPago = 'Olá {nome}, confirmamos o pagamento de R$ {valor} referente a {descricao}. Muito obrigado!';
const fbAtrasado = 'Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:';
const fbCancelado = 'Olá {nome}, a cobrança referente a {descricao} foi cancelada.';
const fbAtualizado = 'Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:';
let templateText = '';
if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado;
else if (eventType === 'PAYMENT_RECEIVED' || eventType === 'PAYMENT_CONFIRMED') templateText = templates?.pagamentoConfirmado || fbPago;
else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado;
else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado;
else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado;
if (!templateText) return;
let msgFinal = templateText
.replace(/{nome}/g, targetName)
.replace(/{nome_aluno}/g, aluno.name)
.replace(/{matricula}/g, aluno.enrollmentNumber || aluno.matricula || '—')
.replace(/{valor}/g, parseFloat(fallbackValor).toFixed(2).replace('.', ','))
.replace(/{vencimento}/g, formatCobrancaDate(typeof fallbackVencimento === 'string' ? fallbackVencimento : ''))
.replace(/{link_boleto}/g, pdfUrl)
.replace(/{descricao}/g, descricao);
const isTextOnlyEvent = ['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED', 'PAYMENT_DELETED'].includes(eventType);
const isPaymentConfirmation = ['PAYMENT_RECEIVED', 'PAYMENT_CONFIRMED'].includes(eventType);
const isCreationEvent = eventType === 'PAYMENT_CREATED';
if (isPaymentConfirmation && pdfUrl && !templateText.includes('{link_boleto}')) {
msgFinal += `\n\n📄 Acesse seu comprovante aqui:\n${pdfUrl}`;
}
let base64Pdf = null;
if (pdfUrl && !isTextOnlyEvent) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const fetchOptions = { headers: { 'Accept': 'application/pdf' } };
if (pdfUrl.includes('asaas.com')) fetchOptions.headers['access_token'] = process.env.ASAAS_API_KEY;
const pdfResp = await fetch(pdfUrl, fetchOptions);
if (pdfResp.ok && pdfResp.headers.get('content-type')?.includes('pdf')) {
const arrayBuffer = await pdfResp.arrayBuffer();
base64Pdf = Buffer.from(arrayBuffer).toString('base64');
break;
}
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
} catch (err) {
if (attempt < 3) await new Promise(r => setTimeout(r, 3000));
}
}
}
if ((isCreationEvent || isPaymentConfirmation || eventType === 'PAYMENT_UPDATED') && !base64Pdf && pdfUrl) {
msgFinal += `\n\n📄 Acesse aqui sua cobrança:\n${pdfUrl}`;
}
let endpoint = 'sendText';
let payload = {};
if (base64Pdf) {
endpoint = 'sendMedia';
let fileName = `Boleto-${targetName.replace(/\s+/g, '')}.pdf`;
if (isCarneCompleto) fileName = `Carne-${targetName.replace(/\s+/g, '')}.pdf`;
if (isPaymentConfirmation) fileName = `Comprovante-${targetName.replace(/\s+/g, '')}.pdf`;
payload = { number: cleanPhone, options: { delay: 1200, presence: "composing" }, mediatype: "document", mimetype: "application/pdf", fileName, media: base64Pdf, caption: msgFinal };
} else {
payload = { number: cleanPhone, text: msgFinal };
}
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/${endpoint}/${evoConfig.instanceName}`;
const sendResp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify(payload) });
if (sendResp.ok) console.log(`[WhatsApp] ✅ Enviado para ${cleanPhone}`);
else console.error(`[WhatsApp] ❌ Erro:`, sendResp.status);
} catch (error) {
console.error('[WhatsApp] Erro interno:', error.message);
}
}
// ============================================================
// Webhook Asaas (Substituídas chamadas supabase por database.js)
// ============================================================
app.post('/api/webhook_asaas', async (req, res) => {
const tokenRecebido = req.headers['asaas-access-token'];
if (tokenRecebido !== process.env.ASAAS_WEBHOOK_TOKEN) {
addLog('Webhook', 'Auth Negada', 'Token inválido');
return res.status(401).json({ error: 'Não autorizado' });
}
try {
const payload = req.body;
if (payload.dateCreated) {
const diffHours = (Date.now() - new Date(payload.dateCreated).getTime()) / (1000 * 60 * 60);
if (diffHours > 24) return res.status(200).send('OK');
}
const asaasPaymentId = payload.payment.id;
let updateData = {};
switch (payload.event) {
case 'PAYMENT_CREATED':
setTimeout(() => sendEvolutionMessage(asaasPaymentId, 'PAYMENT_CREATED'), 2000);
return res.status(200).json({ message: 'OK' });
case 'PAYMENT_RECEIVED':
case 'PAYMENT_CONFIRMED':
updateData = {
status: 'PAGO',
valor: payload.payment.value,
data_pagamento: payload.payment.confirmedDate || payload.payment.paymentDate || new Date().toISOString().split('T')[0]
};
if (payload.payment.transactionReceiptUrl) {
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
}
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
break;
case 'PAYMENT_OVERDUE':
case 'PAYMENT_UPDATED':
case 'PAYMENT_RESTORED':
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' };
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined };
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]);
if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
else if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
break;
case 'PAYMENT_DELETED':
case 'PAYMENT_CANCELED':
const installmentId = payload.payment.installment;
if (installmentId) {
if (cancelCache.has(installmentId)) {
await deleteCobranca(asaasPaymentId);
return res.status(200).send('OK');
}
cancelCache.add(installmentId);
setTimeout(() => cancelCache.delete(installmentId), 60000);
}
await sendEvolutionMessage(asaasPaymentId, 'PAYMENT_DELETED');
await deleteCobranca(asaasPaymentId);
addLog('Webhook', 'PAYMENT_DELETED', { asaasPaymentId });
return res.status(200).send('OK');
default:
return res.status(200).json({ message: 'Evento ignorado' });
}
await updateCobranca(asaasPaymentId, updateData);
addLog('Webhook', `Sucesso ${payload.event}`, { asaasPaymentId });
return res.status(200).json({ message: 'OK' });
} catch (error) {
console.error('Webhook erro:', error);
return res.status(500).json({ error: 'Erro interno' });
}
});
// Webhook Evolution
app.post('/api/webhooks/evolution', (req, res) => {
try {
const payload = req.body;
let messageData = payload.data || payload;
if (messageData.status === 'READ') {
const phone = messageData.key?.remoteJid || 'Desconhecido';
console.log(`👀 [WhatsApp STATUS] Mensagem LIDA: ${phone.split('@')[0]}`);
}
res.status(200).send('OK');
} catch (err) {
res.status(500).send('Erro');
}
});
// ============================================================
// Gerar Cobrança
// ============================================================
app.post('/api/gerar_cobranca', async (req, res) => {
try {
const { aluno_id, nome, cpf, email, valor, vencimento, multa, juros, desconto, telefone, cep, endereco, numero, bairro, descricao, parcelas, nascimento } = req.body;
let customerId = '';
const searchRes = await fetch(`${ASAAS_BASE_URL}/v3/customers?cpfCnpj=${cpf}`, { method: 'GET', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.data?.length > 0) customerId = searchData.data[0].id;
}
if (!customerId) {
const customerRes = await fetch(`${ASAAS_BASE_URL}/v3/customers`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ name: nome, cpfCnpj: cpf, email, mobilePhone: telefone, postalCode: cep, address: endereco, addressNumber: numero, province: bairro, birthDate: nascimento })
});
if (!customerRes.ok) {
const errorData = await customerRes.json();
throw new Error(errorData.errors?.[0]?.description || 'Falha ao criar cliente');
}
customerId = (await customerRes.json()).id;
}
const asaasPayload = { customer: customerId, billingType: 'BOLETO', dueDate: vencimento, description: descricao ? `${descricao} - Microtec Informática Cursos` : 'Mensalidade - Microtec Informática Cursos' };
const isInstallment = parcelas && parseInt(parcelas) > 1;
if (isInstallment) { asaasPayload.installmentCount = parseInt(parcelas); asaasPayload.installmentValue = parseFloat(valor); }
else { asaasPayload.value = parseFloat(valor); }
const fineValue = parseFloat(multa); const interestValue = parseFloat(juros); const discountValue = parseFloat(desconto);
if (!isNaN(fineValue) && fineValue > 0) asaasPayload.fine = { value: fineValue, type: 'PERCENTAGE' };
if (!isNaN(interestValue) && interestValue > 0) asaasPayload.interest = { value: interestValue, type: 'PERCENTAGE' };
if (!isNaN(discountValue) && discountValue > 0) asaasPayload.discount = { value: discountValue, dueDateLimitDays: 0, type: 'FIXED' };
const paymentRes = await fetch(`${ASAAS_BASE_URL}/v3/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY }, body: JSON.stringify(asaasPayload) });
if (!paymentRes.ok) { const e = await paymentRes.json(); throw new Error(e.errors?.[0]?.description || 'Falha Asaas'); }
const paymentData = await paymentRes.json();
let paymentsToSave = [];
const instId = formatInstallmentId(paymentData.installment);
if (isInstallment && instId) {
const installmentsRes = await fetch(`${ASAAS_BASE_URL}/v3/payments?installment=${instId}&limit=100`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (installmentsRes.ok) {
const installmentsData = await installmentsRes.json();
paymentsToSave = installmentsData.data.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)).map(p => ({
aluno_id, asaas_customer_id: customerId, asaas_payment_id: p.id, asaas_installment_id: instId, installment: instId, valor: p.value, vencimento: p.dueDate, link_boleto: p.bankSlipUrl
}));
} else throw new Error('Falha ao buscar parcelas');
} else {
paymentsToSave = [{ aluno_id, asaas_customer_id: customerId, asaas_payment_id: paymentData.id, installment: null, valor: paymentData.value || valor, vencimento: paymentData.dueDate || vencimento, link_boleto: paymentData.bankSlipUrl }];
}
await insertCobrancas(paymentsToSave);
if (paymentsToSave.length > 0) {
sendEvolutionMessage(paymentsToSave[0].asaas_payment_id, 'PAYMENT_CREATED').catch(e => console.error('Erro disparo:', e));
}
return res.status(200).json({ success: true, installment: instId || null, payments: paymentsToSave, bankSlipUrl: paymentsToSave[0]?.link_boleto, paymentId: paymentsToSave[0]?.asaas_payment_id });
} catch (error) {
console.error('Erro gerar cobrança:', error);
return res.status(500).json({ error: error.message });
}
});
// ============================================================
// Disparo em Massa
// ============================================================
app.post('/api/enviar-massa', (req, res) => {
const { alunos, mensagem } = req.body;
if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' });
res.status(200).json({ success: true, message: 'Background iniciado.' });
processarFilaWhatsApp(alunos, mensagem);
});
async function processarFilaWhatsApp(alunos, mensagemTemplate) {
const appData = await getSchoolData();
const evoConfig = appData?.evolutionConfig;
if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return;
for (let i = 0; i < alunos.length; i++) {
const aluno = alunos[i];
const msg = mensagemTemplate.replace(/{nome}/g, aluno.nome).replace(/{matricula}/g, aluno.matricula || '—');
try {
let cleanPhone = aluno.telefone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
} catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); }
if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 120000) + 60000));
}
}
// ============================================================
// Logs
// ============================================================
const apiLogs = [];
function addLog(service, action, details) {
apiLogs.unshift({ date: new Date().toISOString(), service, action, details });
if (apiLogs.length > 200) apiLogs.pop();
}
app.get('/api/logs', (req, res) => res.json(apiLogs));
const isUUID = (str) => typeof str === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
const formatInstallmentId = (id) => { if (!id) return id; if (id.startsWith('inst_')) return id.replace('inst_', 'ins_'); return id; };
// ============================================================
// Exclusão de Cobrança
// ============================================================
app.post('/api/excluir_cobranca', async (req, res) => {
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID não fornecido' });
const parcelas = await getCobrancasByOrQuery(id);
let isSinglePayment = id.startsWith('pay_');
if (!isSinglePayment) {
const asaasTargetId = formatInstallmentId(id);
const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (resp.ok) addLog('Asaas', 'Exclusão Parcelamento OK', { id });
} else {
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
}
return res.status(200).json({ message: 'Excluído no Asaas (Aguardando Webhook)' });
} catch (error) {
console.error('[Exclusão] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Carnês e Links
// ============================================================
app.get('/api/parcelamentos/:id/carne', async (req, res) => {
try {
const id = req.params.id;
const parcelas = await getCobrancasByOrQuery(id);
let instId = (!id.startsWith('pay_')) ? id : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
if (instId) {
const asaasTargetInstId = formatInstallmentId(instId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.status(200).json({ status: 'success', type: 'pdf', url: pSaved.link_carne });
const ar = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (ar.ok && ar.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await ar.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(instId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
}
const boletos = parcelas ? parcelas.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id })) : [];
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.get('/api/cobrancas/:id/link', async (req, res) => {
try {
const p = await fetch(`${ASAAS_BASE_URL}/v3/payments/${req.params.id}`, { headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!p.ok) return res.status(404).json({ error: 'Não encontrada.' });
const d = await p.json();
return res.status(200).json({ bankSlipUrl: d.bankSlipUrl || d.invoiceUrl, transactionReceiptUrl: d.transactionReceiptUrl });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.patch('/api/alunos/:id/rematricular', async (req, res) => res.json({ success: true }));
app.put('/api/cobrancas/:id', async (req, res) => {
try {
const { id } = req.params;
const { valor, vencimento } = req.body;
let targetAsaasId = id;
if (isUUID(id)) {
const parcelas = await getCobrancasByOrQuery(id);
if (parcelas.length > 0 && parcelas[0].asaas_payment_id) targetAsaasId = parcelas[0].asaas_payment_id;
}
const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ value: valor, dueDate: vencimento })
});
if (!aResp.ok) { const err = await aResp.json().catch(() => ({})); return res.status(400).json({ error: err.errors?.[0]?.description || 'Erro Asaas' }); }
const queryField = isUUID(id) ? 'id' : 'asaas_payment_id';
await updateCobrancaByField(queryField, id, { valor, vencimento });
res.json({ message: 'Editado com sucesso' });
} catch (e) { res.status(500).json({ error: 'Erro interno.' }); }
});
app.get('/api/alunos/:id/carne', async (req, res) => {
try {
const cobrancas = await getCobrancasByAlunoId(req.params.id);
const withInstallment = cobrancas.filter(c => c.asaas_installment_id);
if (withInstallment.length === 0) return res.status(404).json({ error: 'Nenhum carnê.' });
const latestInstId = withInstallment[withInstallment.length - 1].asaas_installment_id;
const asaasTargetInstId = formatInstallmentId(latestInstId);
const binResp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (binResp.ok && binResp.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await binResp.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
const publicUrl = await uploadCarneToStorage(fileName, buffer);
await updateCobrancaLinkCarne(latestInstId, publicUrl);
return res.status(200).json({ status: 'success', type: 'pdf', url: publicUrl });
}
const allCobs = await getCobrancasByInstallmentId(latestInstId);
const boletos = allCobs.map((c, i) => ({ id: c.id, numero: i + 1, vencimento: c.vencimento, valor: c.valor, linkBoleto: c.link_boleto, status: c.status, asaasPaymentId: c.asaas_payment_id }));
return res.status(200).json({ status: 'success', type: 'fallback', boletos });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// ============================================================
// INICIALIZAÇÃO
// ============================================================
async function startServer() {
const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.use((req, res, next) => req.path.startsWith('/api') ? next() : res.sendFile(path.join(distPath, 'index.html')));
} else {
const vite = await import('vite').then(m => m.createServer({ server: { middlewareMode: true }, appType: 'spa' }));
app.use(vite.middlewares);
}
// Disparo Manual de Inadimplência
app.post('/api/disparar_cobrancas', async (req, res) => {
try {
const atrasados = await getCobrancasAtrasadas();
if (atrasados.length === 0) return res.status(200).json({ message: 'Nenhuma atrasada.' });
let enviadas = 0;
for (const cob of atrasados) {
if (cob.asaas_payment_id) { await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); enviadas++; }
}
return res.status(200).json({ message: `${enviadas} mensagens processadas.` });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
// Imprimir Carnê
app.get('/api/imprimir-carne/:installmentId', async (req, res) => {
try {
const { installmentId } = req.params;
const parcelas = await getCobrancasByOrQuery(installmentId);
let instId = (!installmentId.startsWith('pay_')) ? installmentId : null;
if (!instId && parcelas?.length > 0) { const p = parcelas.find(x => x.asaas_installment_id); if (p) instId = p.asaas_installment_id; }
const asaasTargetInstId = formatInstallmentId(instId || installmentId);
const pSaved = parcelas?.find(x => x.link_carne);
if (pSaved?.link_carne) return res.redirect(pSaved.link_carne);
let asaasUrl = `${ASAAS_BASE_URL}/v3/installments/${asaasTargetInstId}/paymentBook`;
const { sort, order } = req.query;
const params = new URLSearchParams();
if (sort) params.append('sort', sort);
if (order) params.append('order', order);
if (params.toString()) asaasUrl += `?${params.toString()}`;
const response = await fetch(asaasUrl, { headers: { 'access_token': process.env.ASAAS_API_KEY, 'Accept': 'application/pdf' } });
if (response.ok && response.headers.get('content-type')?.includes('pdf')) {
const buffer = Buffer.from(await response.arrayBuffer());
const fileName = `carne_${asaasTargetInstId}.pdf`;
// Upload assíncrono para MinIO
uploadCarneToStorage(fileName, buffer).then(publicUrl => {
updateCobrancaLinkCarne(instId, publicUrl).catch(() => {});
}).catch(() => {});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="carne.pdf"');
return res.send(buffer);
} else {
return res.status(response.status).send('Falha Asaas');
}
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.listen(PORT, '0.0.0.0', () => console.log(`🚀 EduManager Self-Hosted na porta ${PORT}`));
}
startServer();

View File

@ -0,0 +1,204 @@
/**
* ============================================================
* SERVIÇO DE BANCO DE DADOS PostgreSQL (Self-Hosted)
* Substitui todas as chamadas supabase.from(...) do sistema
* ============================================================
*/
import pg from 'pg';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({
connectionString: DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('[PostgreSQL] Erro inesperado no pool:', err);
});
// ============================================================
// HELPER: Buscar school_data JSON blob (compatibilidade legada)
// ============================================================
export async function getSchoolData() {
const { rows } = await pool.query(
'SELECT data FROM school_data WHERE id = 1'
);
return rows[0]?.data || {};
}
export async function saveSchoolData(data) {
await pool.query(
`INSERT INTO school_data (id, data, updated_at)
VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = NOW()`,
[JSON.stringify(data)]
);
}
// ============================================================
// HELPERS: alunos_cobrancas
// ============================================================
export async function insertCobrancas(cobrancas) {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const c of cobrancas) {
await client.query(
`INSERT INTO alunos_cobrancas
(aluno_id, asaas_customer_id, asaas_payment_id, asaas_installment_id, installment, valor, vencimento, link_boleto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
export async function updateCobranca(asaasPaymentId, updateData) {
const setClauses = [];
const values = [];
let i = 1;
for (const [key, value] of Object.entries(updateData)) {
if (value !== undefined) {
setClauses.push(`${key} = $${i}`);
values.push(value);
i++;
}
}
if (setClauses.length === 0) return;
values.push(asaasPaymentId);
await pool.query(
`UPDATE alunos_cobrancas SET ${setClauses.join(', ')} WHERE asaas_payment_id = $${i}`,
values
);
}
export async function deleteCobranca(asaasPaymentId) {
await pool.query(
'DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1',
[asaasPaymentId]
);
}
export async function getCobrancaByPaymentId(asaasPaymentId) {
const { rows } = await pool.query(
'SELECT * FROM alunos_cobrancas WHERE asaas_payment_id = $1',
[asaasPaymentId]
);
return rows[0] || null;
}
export async function getCobrancasByOrQuery(id) {
// Replicates: supabase.from('alunos_cobrancas').select('*').or(...)
const { rows } = await pool.query(
`SELECT * FROM alunos_cobrancas
WHERE installment = $1
OR asaas_installment_id = $1
OR asaas_payment_id = $1
OR id::text = $1
ORDER BY vencimento ASC`,
[id]
);
return rows;
}
export async function getCobrancasByAlunoId(alunoId) {
const { rows } = await pool.query(
'SELECT * FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC',
[alunoId]
);
return rows;
}
export async function getCobrancasAtrasadas() {
const { rows } = await pool.query(
"SELECT * FROM alunos_cobrancas WHERE status = 'ATRASADO'"
);
return rows;
}
export async function getCobrancasByInstallmentId(installmentId) {
const { rows } = await pool.query(
'SELECT * FROM alunos_cobrancas WHERE asaas_installment_id = $1 ORDER BY vencimento ASC',
[installmentId]
);
return rows;
}
export async function updateCobrancaLinkCarne(installmentId, linkCarne) {
await pool.query(
'UPDATE alunos_cobrancas SET link_carne = $1 WHERE asaas_installment_id = $2',
[linkCarne, installmentId]
);
}
export async function updateCobrancaByField(field, id, updateData) {
const setClauses = [];
const values = [];
let i = 1;
for (const [key, value] of Object.entries(updateData)) {
if (value !== undefined) {
setClauses.push(`${key} = $${i}`);
values.push(value);
i++;
}
}
if (setClauses.length === 0) return;
values.push(id);
await pool.query(
`UPDATE alunos_cobrancas SET ${setClauses.join(', ')} WHERE ${field} = $${i}`,
values
);
}
// ============================================================
// HELPERS: provas_submissoes
// ============================================================
export async function getSubmissoesByAluno(alunoId) {
const { rows } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1',
[alunoId]
);
return rows;
}
export async function getSubmissaoByAlunoAndExam(alunoId, examId) {
const { rows } = await pool.query(
'SELECT id FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[alunoId, examId]
);
return rows;
}
export async function insertSubmissao(submission) {
await pool.query(
`INSERT INTO provas_submissoes (aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
submission.aluno_id, submission.exam_id,
submission.total_questions, submission.correct_count, submission.wrong_count,
submission.percentage, submission.final_score,
JSON.stringify(submission.answers_json), submission.created_at
]
);
}
// ============================================================
// EXPORT POOL para queries diretas quando necessário
// ============================================================
export { pool };
export default pool;

View File

@ -0,0 +1,349 @@
/**
* ============================================================
* SERVIÇO DE DADOS SELF-HOSTED (API HTTP)
* ============================================================
* Substitui a lógica de fetchFromCloud/saveToCloud
* de @supabase/supabase-js por chamadas HTTP ao backend.
*
* IndexedDB e localStorage continuam como cache local.
* NENHUMA tela foi alterada.
* ============================================================
*/
import { SchoolData } from '../types';
const STORAGE_KEY = 'edumanager_db_v1';
const initialContractTemplate = `CONTRATO DE PRESTAÇÃO DE SERVIÇOS EDUCACIONAIS
Pelo presente instrumento particular, de um lado {{escola}} (CNPJ: {{cnpj_escola}}), e de outro lado o(a) aluno(a) {{aluno}}, celebram o presente contrato:
1. DO OBJETO: Prestação de serviços educacionais no curso de {{curso}}.
2. DA DURAÇÃO: O curso terá a duração estimada de {{duracao}}.
3. DO INVESTIMENTO: O CONTRATANTE pagará o valor mensal de R$ {{mensalidade}}.
4. DAS OBRIGAÇÕES: A CONTRATADA disponibilizará material e instrutores qualificados.
Data: {{data}}
___________________________________________
Assinatura do Aluno / Responsável`;
const initialData: SchoolData = {
users: [],
courses: [],
students: [],
classes: [],
payments: [],
contracts: [],
certificates: [],
attendance: [],
subjects: [],
periods: [],
grades: [],
handouts: [],
handoutDeliveries: [],
employees: [],
employeeCategories: [],
lessons: [],
notifications: [],
exams: [],
profile: {
id: 'main-school',
name: 'EduManager School',
address: '',
city: '',
state: '',
zip: '',
cnpj: '',
phone: '',
email: '',
type: 'matriz'
},
logo: '',
profiles: [
{
id: 'main-school',
name: 'EduManager School',
address: '',
city: '',
state: '',
zip: '',
cnpj: '',
phone: '',
email: '',
type: 'matriz'
}
],
contractTemplates: [
{
id: 'default-template',
name: 'Contrato Padrão',
content: initialContractTemplate
}
],
lastUpdated: new Date(0).toISOString()
};
const DB_NAME = 'EduManagerDB';
const STORE_NAME = 'school_data';
const DB_VERSION = 1;
// Helper to open DB (IndexedDB — cache local mantido)
const openDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
};
export const dbService = {
// Initialize and get data (Async)
initData: async (): Promise<SchoolData> => {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(STORAGE_KEY);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const data = request.result;
const defaultData = JSON.parse(JSON.stringify(initialData));
if (!data) {
// Fallback to localStorage migration if IDB is empty
const localData = localStorage.getItem(STORAGE_KEY);
if (localData) {
try {
const parsedLocal = JSON.parse(localData);
resolve({ ...defaultData, ...parsedLocal });
return;
} catch (e) {
// ignore
}
}
resolve(defaultData);
return;
}
const parsed = data;
const finalObj = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
const users = Array.isArray(finalObj.users) ? finalObj.users : [];
const finalData = {
...defaultData,
...finalObj,
users: users,
profile: { ...defaultData.profile, ...(finalObj.profile || {}) },
profiles: Array.isArray(finalObj.profiles) ? finalObj.profiles : (finalObj.profile ? [{ ...defaultData.profile, ...finalObj.profile }] : defaultData.profiles),
logo: finalObj.logo || finalObj.profile?.logo || ''
};
if (finalData.users.length === 0) {
finalData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
}
resolve(finalData);
};
});
} catch (error) {
console.error("Error loading IDB data", error);
const fallbackData = JSON.parse(JSON.stringify(initialData));
fallbackData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
return fallbackData;
}
},
// Synchronous Local Load (Fallback)
getData: (): SchoolData => {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
return JSON.parse(data);
}
} catch (e) {
// ignore
}
return JSON.parse(JSON.stringify(initialData));
},
// ============================================================
// MUDANÇA PRINCIPAL: fetchFromCloud agora usa API HTTP
// Em vez de: supabase.from('school_data').select('data')
// Agora usa: fetch('/api/school-data')
// ============================================================
fetchFromCloud: async (): Promise<SchoolData | null> => {
try {
const response = await fetch('/api/school-data');
if (!response.ok) {
console.error("Erro ao buscar dados do servidor:", response.status);
return null;
}
const result = await response.json();
if (result && result.data) {
const fetchedData = result.data;
const defaultData = JSON.parse(JSON.stringify(initialData));
if (!fetchedData.users || !Array.isArray(fetchedData.users) || fetchedData.users.length === 0) {
fetchedData.users = defaultData.users;
fetchedData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
}
return {
...defaultData,
...fetchedData
};
}
return null;
} catch (err) {
console.error("Erro ao buscar dados:", err);
return null;
}
},
saveData: async (data: SchoolData) => {
try {
// Update timestamp
data.lastUpdated = new Date().toISOString();
// Save to IndexedDB (cache local)
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.put(data, STORAGE_KEY);
// Try localStorage backup
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn("LocalStorage quota exceeded, relying on IndexedDB");
}
} catch (e) {
console.error("Error saving data", e);
}
},
// ============================================================
// MUDANÇA PRINCIPAL: saveToCloud agora usa API HTTP
// Em vez de: supabase.from('school_data').upsert(...)
// Agora usa: fetch('/api/school-data', { method: 'PUT' })
// ============================================================
saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error' }> => {
try {
const response = await fetch('/api/school-data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
if (response.status === 409 && result.reason === 'newer_version') {
console.warn("Servidor tem versão mais nova. Abortando save.");
return { success: false, reason: 'newer_version' };
}
throw new Error('Erro ao salvar');
}
return { success: true };
} catch (e) {
console.error("Erro ao salvar na nuvem:", e);
return { success: false, reason: 'error' };
}
},
exportData: async () => {
const data = await dbService.initData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `edumanager_backup_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
},
importData: (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const json = JSON.parse(e.target?.result as string);
await dbService.saveData(json);
await dbService.saveToCloud(json);
resolve();
} catch (err) {
reject(new Error('Invalid backup file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
},
resetData: async (): Promise<void> => {
try {
localStorage.clear();
const db = await openDB();
return new Promise((resolve) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const req = store.delete(STORAGE_KEY);
req.onsuccess = async () => {
const resetState = JSON.parse(JSON.stringify(initialData));
resetState.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
// Salvar no servidor via API
try {
await fetch('/api/school-data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(resetState),
});
} catch(e) {}
resolve();
};
req.onerror = () => resolve();
});
} catch (e) {
console.error(e);
}
}
};

View File

@ -0,0 +1,349 @@
/**
* ============================================================
* SERVIÇO DE DADOS SELF-HOSTED (API HTTP)
* ============================================================
* Substitui a lógica de fetchFromCloud/saveToCloud
* de @supabase/supabase-js por chamadas HTTP ao backend.
*
* IndexedDB e localStorage continuam como cache local.
* NENHUMA tela foi alterada.
* ============================================================
*/
import { SchoolData } from '../types';
const STORAGE_KEY = 'edumanager_db_v1';
const initialContractTemplate = `CONTRATO DE PRESTAÇÃO DE SERVIÇOS EDUCACIONAIS
Pelo presente instrumento particular, de um lado {{escola}} (CNPJ: {{cnpj_escola}}), e de outro lado o(a) aluno(a) {{aluno}}, celebram o presente contrato:
1. DO OBJETO: Prestação de serviços educacionais no curso de {{curso}}.
2. DA DURAÇÃO: O curso terá a duração estimada de {{duracao}}.
3. DO INVESTIMENTO: O CONTRATANTE pagará o valor mensal de R$ {{mensalidade}}.
4. DAS OBRIGAÇÕES: A CONTRATADA disponibilizará material e instrutores qualificados.
Data: {{data}}
___________________________________________
Assinatura do Aluno / Responsável`;
const initialData: SchoolData = {
users: [],
courses: [],
students: [],
classes: [],
payments: [],
contracts: [],
certificates: [],
attendance: [],
subjects: [],
periods: [],
grades: [],
handouts: [],
handoutDeliveries: [],
employees: [],
employeeCategories: [],
lessons: [],
notifications: [],
exams: [],
profile: {
id: 'main-school',
name: 'EduManager School',
address: '',
city: '',
state: '',
zip: '',
cnpj: '',
phone: '',
email: '',
type: 'matriz'
},
logo: '',
profiles: [
{
id: 'main-school',
name: 'EduManager School',
address: '',
city: '',
state: '',
zip: '',
cnpj: '',
phone: '',
email: '',
type: 'matriz'
}
],
contractTemplates: [
{
id: 'default-template',
name: 'Contrato Padrão',
content: initialContractTemplate
}
],
lastUpdated: new Date(0).toISOString()
};
const DB_NAME = 'EduManagerDB';
const STORE_NAME = 'school_data';
const DB_VERSION = 1;
// Helper to open DB (IndexedDB — cache local mantido)
const openDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
};
export const dbService = {
// Initialize and get data (Async)
initData: async (): Promise<SchoolData> => {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(STORAGE_KEY);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const data = request.result;
const defaultData = JSON.parse(JSON.stringify(initialData));
if (!data) {
// Fallback to localStorage migration if IDB is empty
const localData = localStorage.getItem(STORAGE_KEY);
if (localData) {
try {
const parsedLocal = JSON.parse(localData);
resolve({ ...defaultData, ...parsedLocal });
return;
} catch (e) {
// ignore
}
}
resolve(defaultData);
return;
}
const parsed = data;
const finalObj = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
const users = Array.isArray(finalObj.users) ? finalObj.users : [];
const finalData = {
...defaultData,
...finalObj,
users: users,
profile: { ...defaultData.profile, ...(finalObj.profile || {}) },
profiles: Array.isArray(finalObj.profiles) ? finalObj.profiles : (finalObj.profile ? [{ ...defaultData.profile, ...finalObj.profile }] : defaultData.profiles),
logo: finalObj.logo || finalObj.profile?.logo || ''
};
if (finalData.users.length === 0) {
finalData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
}
resolve(finalData);
};
});
} catch (error) {
console.error("Error loading IDB data", error);
const fallbackData = JSON.parse(JSON.stringify(initialData));
fallbackData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
return fallbackData;
}
},
// Synchronous Local Load (Fallback)
getData: (): SchoolData => {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
return JSON.parse(data);
}
} catch (e) {
// ignore
}
return JSON.parse(JSON.stringify(initialData));
},
// ============================================================
// MUDANÇA PRINCIPAL: fetchFromCloud agora usa API HTTP
// Em vez de: supabase.from('school_data').select('data')
// Agora usa: fetch('/api/school-data')
// ============================================================
fetchFromCloud: async (): Promise<SchoolData | null> => {
try {
const response = await fetch('/api/school-data');
if (!response.ok) {
console.error("Erro ao buscar dados do servidor:", response.status);
return null;
}
const result = await response.json();
if (result && result.data) {
const fetchedData = result.data;
const defaultData = JSON.parse(JSON.stringify(initialData));
if (!fetchedData.users || !Array.isArray(fetchedData.users) || fetchedData.users.length === 0) {
fetchedData.users = defaultData.users;
fetchedData.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
}
return {
...defaultData,
...fetchedData
};
}
return null;
} catch (err) {
console.error("Erro ao buscar dados:", err);
return null;
}
},
saveData: async (data: SchoolData) => {
try {
// Update timestamp
data.lastUpdated = new Date().toISOString();
// Save to IndexedDB (cache local)
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.put(data, STORAGE_KEY);
// Try localStorage backup
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn("LocalStorage quota exceeded, relying on IndexedDB");
}
} catch (e) {
console.error("Error saving data", e);
}
},
// ============================================================
// MUDANÇA PRINCIPAL: saveToCloud agora usa API HTTP
// Em vez de: supabase.from('school_data').upsert(...)
// Agora usa: fetch('/api/school-data', { method: 'PUT' })
// ============================================================
saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error' }> => {
try {
const response = await fetch('/api/school-data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
if (response.status === 409 && result.reason === 'newer_version') {
console.warn("Servidor tem versão mais nova. Abortando save.");
return { success: false, reason: 'newer_version' };
}
throw new Error('Erro ao salvar');
}
return { success: true };
} catch (e) {
console.error("Erro ao salvar na nuvem:", e);
return { success: false, reason: 'error' };
}
},
exportData: async () => {
const data = await dbService.initData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `edumanager_backup_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
},
importData: (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const json = JSON.parse(e.target?.result as string);
await dbService.saveData(json);
await dbService.saveToCloud(json);
resolve();
} catch (err) {
reject(new Error('Invalid backup file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
},
resetData: async (): Promise<void> => {
try {
localStorage.clear();
const db = await openDB();
return new Promise((resolve) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const req = store.delete(STORAGE_KEY);
req.onsuccess = async () => {
const resetState = JSON.parse(JSON.stringify(initialData));
resetState.users.push({
id: 'default-admin',
name: 'admin',
displayName: 'Administrador',
password: 'admin',
cpf: '000.000.000-00',
role: 'admin'
});
// Salvar no servidor via API
try {
await fetch('/api/school-data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(resetState),
});
} catch(e) {}
resolve();
};
req.onerror = () => resolve();
});
} catch (e) {
console.error(e);
}
}
};

View File

@ -0,0 +1,45 @@
import { GoogleGenAI } from "@google/genai";
import { SchoolData } from "../types";
export const geminiService = {
getAIAnalysis: async (prompt: string, context: SchoolData) => {
// Always initialize GoogleGenAI with a named parameter using process.env.API_KEY directly.
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Minimal data extraction to avoid token overflow
const summaryContext = {
totalStudents: context.students.length,
activeStudents: context.students.filter(s => s.status === 'active').length,
// Fix: Resolve course name from courseId since Class doesn't have courseName
classes: context.classes.map(c => {
const course = context.courses.find(crs => crs.id === c.courseId);
return { name: c.name, course: course?.name || 'N/A' };
}),
totalPendingPayments: context.payments.filter(p => p.status !== 'paid').length
};
const systemInstruction = `
Você é um assistente especializado em gestão escolar para escolas de informática.
Use os dados fornecidos para gerar relatórios, sugestões de contratos ou insights financeiros.
Responda de forma profissional e concisa em Português do Brasil.
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `Contexto da Escola: ${JSON.stringify(summaryContext)}\n\nUsuário pergunta: ${prompt}`,
config: {
systemInstruction,
temperature: 0.7,
}
});
// Directly access .text property as per GenerateContentResponse definition.
return response.text || "Desculpe, não consegui processar sua solicitação.";
} catch (error) {
console.error("Gemini Error:", error);
return "Erro ao conectar com a IA. Verifique sua chave de API.";
}
}
};

View File

@ -0,0 +1,62 @@
/**
* Utility service for image processing and compression
*/
export const compressImage = (file: File | string, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
return new Promise((resolve, reject) => {
const process = (src: string) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = (maxWidth / width) * height;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
// Try WebP first, fallback to JPEG
const dataUrl = canvas.toDataURL('image/webp', quality);
if (dataUrl.startsWith('data:image/webp')) {
resolve(dataUrl);
} else {
resolve(canvas.toDataURL('image/jpeg', quality));
}
};
img.onerror = reject;
img.src = src;
};
if (file instanceof File) {
const reader = new FileReader();
reader.onload = (e) => process(e.target?.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
} else {
process(file);
}
});
};
/**
* Gets the dimensions of an image from a base64 string or URL
*/
export const getImageDimensions = (src: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = reject;
img.src = src;
});
};

View File

@ -0,0 +1,894 @@
import { jsPDF } from 'jspdf';
import 'jspdf-autotable';
import { SchoolData, Student, Contract, Payment, Class } from '../types';
import { getImageDimensions } from './imageService';
/**
* Helper to calculate proportional dimensions and add image to PDF
*/
const addImageProportional = async (doc: any, src: string, x: number, y: number, maxW: number, maxH: number) => {
try {
const { width, height } = await getImageDimensions(src);
const ratio = width / height;
let finalW = maxW;
let finalH = maxW / ratio;
if (finalH > maxH) {
finalH = maxH;
finalW = maxH * ratio;
}
// Center in the box
const offsetX = (maxW - finalW) / 2;
const offsetY = (maxH - finalH) / 2;
let format = 'JPEG';
const lowerSrc = src.toLowerCase();
if (lowerSrc.includes('png')) format = 'PNG';
else if (lowerSrc.includes('webp')) format = 'WEBP';
doc.addImage(src, format, x + offsetX, y + offsetY, finalW, finalH, undefined, 'FAST');
return { width: finalW, height: finalH };
} catch (e) {
console.warn("Image failed to load in PDF", e);
return null;
}
};
/**
* Helper to process and add a 3x4 student photo with center crop and compression
*/
const addStudentPhoto3x4 = async (doc: any, src: string, x: number, y: number) => {
return new Promise<{ width: number, height: number } | null>((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement('canvas');
// 300x400 for 300 DPI approx (30mm x 40mm)
const targetW = 354; // 30mm at 300 DPI is approx 354px
const targetH = 472; // 40mm at 300 DPI is approx 472px
canvas.width = targetW;
canvas.height = targetH;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(null);
return;
}
const imgRatio = img.width / img.height;
const targetRatio = targetW / targetH;
let sourceW, sourceH, sourceX, sourceY;
if (imgRatio > targetRatio) {
// Image is wider than 3x4 - crop sides
sourceH = img.height;
sourceW = img.height * targetRatio;
sourceX = (img.width - sourceW) / 2;
sourceY = 0;
} else {
// Image is taller than 3x4 - crop top/bottom
sourceW = img.width;
sourceH = img.width / targetRatio;
sourceX = 0;
sourceY = (img.height - sourceH) / 2;
}
ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, targetW, targetH);
// Compress to JPEG with 0.6 quality for instant opening
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
doc.addImage(dataUrl, 'JPEG', x, y, 30, 40, undefined, 'FAST');
resolve({ width: 30, height: 40 });
};
img.onerror = () => resolve(null);
img.src = src;
});
};
/**
* Helper to add header/logo to PDF
*/
export const addHeader = async (doc: any, schoolData: SchoolData) => {
const profile = schoolData.profile;
if (schoolData.logo) {
await addImageProportional(doc, schoolData.logo, 20, 10, 25, 25);
}
doc.setFontSize(12);
doc.setTextColor(0);
doc.setFont('helvetica', 'bold');
doc.text(profile.name || 'EduManager School', 50, 18);
doc.setFontSize(8);
doc.setTextColor(0);
doc.setFont('helvetica', 'normal');
doc.text(`CNPJ: ${profile.cnpj || 'Não informado'}`, 50, 23);
doc.text(profile.address || '', 50, 27);
doc.text(`${profile.phone || ''} ${profile.email ? '| ' + profile.email : ''}`, 50, 31);
doc.setDrawColor(0);
doc.setLineWidth(0.1);
doc.line(20, 38, 190, 38);
return 45;
};
/**
* Helper to calculate age and get signer info
*/
const getSignerInfo = (student: Student) => {
if (!student.birthDate) {
return {
name: student.guardianName || student.name,
cpf: student.guardianCpf || student.cpf,
label: student.guardianName ? 'ASSINATURA DO RESPONSÁVEL' : 'ASSINATURA DO ALUNO'
};
}
const today = new Date();
const birth = new Date(student.birthDate);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
age--;
}
if (age >= 18) {
return {
name: student.name,
cpf: student.cpf,
label: 'ASSINATURA DO ALUNO'
};
} else {
return {
name: student.guardianName || 'NÃO INFORMADO',
cpf: student.guardianCpf || '---',
label: 'ASSINATURA DO RESPONSÁVEL'
};
}
};
/**
* Helper to get Director info from employees
*/
const getDirectorInfo = (schoolData: SchoolData) => {
const employees = schoolData.employees || [];
const categories = schoolData.employeeCategories || [];
const director = employees.find(e => {
const cat = categories.find(c => c.id === e.categoryId);
const catName = cat?.name.toLowerCase() || '';
const empName = e.name.toLowerCase();
const roleMatch = catName.includes('diretor') || catName.includes('diretoria');
const nameMatch = empName.includes('diretor') || empName.includes('diretoria');
return roleMatch || nameMatch;
});
if (director) {
return {
name: director.name,
cpf: director.cpf,
role: 'Diretor'
};
}
return null;
};
/**
* Helper to add page numbers to footer
*/
const addPageNumbers = (doc: any) => {
const pageCount = doc.internal.getNumberOfPages();
doc.setFont('helvetica', 'normal');
doc.setFontSize(7);
doc.setTextColor(0);
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.text(
`Página ${i} de ${pageCount}`,
doc.internal.pageSize.width / 2,
doc.internal.pageSize.height - 10,
{ align: 'center' }
);
}
};
/**
* Helper to draw justified text with paragraph support
*/
const drawJustifiedText = async (doc: any, text: string, x: number, y: number, maxWidth: number, lineHeight: number, schoolData: SchoolData) => {
const paragraphs = text.split('\n').filter(p => p.trim() !== '');
let currentY = y;
const margin = x;
const pageHeight = doc.internal.pageSize.height;
for (const p of paragraphs) {
const isClause = p.toUpperCase().startsWith('CLÁUSULA') || p.toUpperCase().startsWith('CLAUSULA');
// Check for page break before paragraph
if (currentY > pageHeight - 30) {
doc.addPage();
await addHeader(doc, schoolData);
currentY = 50;
}
doc.setFont('helvetica', isClause ? 'bold' : 'normal');
doc.setFontSize(9);
// Indent for non-clause paragraphs
const startX = isClause ? margin : margin + 10;
const currentMaxWidth = isClause ? maxWidth : maxWidth - 10;
const lines = doc.splitTextToSize(p, currentMaxWidth);
for (let i = 0; i < lines.length; i++) {
// Check for page break before line
if (currentY > pageHeight - 20) {
doc.addPage();
await addHeader(doc, schoolData);
currentY = 50;
doc.setFont('helvetica', isClause ? 'bold' : 'normal');
}
const line = lines[i];
const isLastLine = i === lines.length - 1;
if (isClause || isLastLine || line.trim().length < (currentMaxWidth / 4)) {
doc.text(line, startX, currentY);
} else {
// Justify line
const words = line.trim().split(/\s+/);
if (words.length > 1) {
const totalWordsWidth = words.reduce((sum: number, word: string) => sum + doc.getTextWidth(word), 0);
const totalSpacing = currentMaxWidth - totalWordsWidth;
const spacingPerWord = totalSpacing / (words.length - 1);
let currentX = startX;
for (let j = 0; j < words.length; j++) {
doc.text(words[j], currentX, currentY);
currentX += doc.getTextWidth(words[j]) + spacingPerWord;
}
} else {
doc.text(line, startX, currentY);
}
}
currentY += lineHeight;
}
currentY += 4; // Space between paragraphs
}
return currentY;
};
/**
* Helper to draw justified text specifically for the contract
*/
const drawContractText = async (doc: any, text: string, x: number, y: number, maxWidth: number, lineHeight: number, schoolData: SchoolData) => {
let cleanText = text
.replace(/PROFISSINALIZANTE/g, 'PROFISSIONALIZANTE')
.replace(/CONTRADA/g, 'CONTRATADA')
.replace(/terar/g, 'terá')
.replace(/apredisagem/g, 'aprendizagem');
// Preserve the exact organization by splitting strictly by newline
let paragraphs = cleanText.split('\n');
let currentY = y;
const margin = x;
const pageHeight = doc.internal.pageSize.height;
const bottomMargin = 20;
const fontSize = 11; // Uniform font size for the body
for (const p of paragraphs) {
if (!p.trim()) {
currentY += lineHeight * 0.5; // Provide spacing for empty lines
continue;
}
const isClause = /^(CLÁUSULA|CLAUSULA)/i.test(p.trim());
if (currentY > pageHeight - bottomMargin - 10) {
doc.addPage();
currentY = await addHeader(doc, schoolData);
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(fontSize);
let title = "";
let restOfText = p.trim();
if (isClause) {
const match = restOfText.match(/^(CLÁUSULA\s+\d+.*?[-–—:]\s*|CLAUSULA\s+\d+.*?[-–—:]\s*)/i);
if (match) {
title = match[0];
restOfText = restOfText.substring(title.length).trim();
} else {
const match2 = restOfText.match(/^(CLÁUSULA\s+\d+|CLAUSULA\s+\d+)/i);
if (match2) {
title = match2[0] + " - ";
restOfText = restOfText.substring(match2[0].length).trim();
}
}
}
const startX = margin;
const currentMaxWidth = maxWidth;
if (title) {
if (currentY > pageHeight - bottomMargin - 10) {
doc.addPage();
currentY = await addHeader(doc, schoolData);
}
doc.setFont('helvetica', 'bold');
doc.text(title, startX, currentY);
currentY += lineHeight;
doc.setFont('helvetica', 'normal');
}
if (!restOfText) {
currentY += lineHeight * 0.5;
continue;
}
const lines = doc.splitTextToSize(restOfText, currentMaxWidth);
for (let i = 0; i < lines.length; i++) {
if (currentY > pageHeight - bottomMargin) {
doc.addPage();
currentY = await addHeader(doc, schoolData);
doc.setFont('helvetica', 'normal');
doc.setFontSize(fontSize);
}
const line = lines[i];
const isLastLine = i === lines.length - 1;
if (isLastLine || line.trim().length < (currentMaxWidth / 2)) {
doc.text(line, startX, currentY);
} else {
// Justify line
const words = line.trim().split(/\s+/);
if (words.length > 1) {
const totalWordsWidth = words.reduce((sum: number, word: string) => sum + doc.getTextWidth(word), 0);
const totalSpacing = currentMaxWidth - totalWordsWidth;
const spacingPerWord = totalSpacing / (words.length - 1);
let currentX = startX;
for (let j = 0; j < words.length; j++) {
doc.text(words[j], currentX, currentY);
currentX += doc.getTextWidth(words[j]) + spacingPerWord;
}
} else {
doc.text(line, startX, currentY);
}
}
currentY += lineHeight;
}
currentY += lineHeight * 0.5; // Space between paragraphs
}
return currentY;
};
export const pdfService = {
generateStudentRegistrationPDF: async (student: Student, schoolData: SchoolData) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
const cls = schoolData.classes.find(c => c.id === student.classId);
const course = schoolData.courses.find(c => c.id === cls?.courseId);
// Title and Date (Centered)
doc.setFontSize(16);
doc.setTextColor(0);
doc.setFont('helvetica', 'bold');
doc.text('Ficha de Matrícula', 105, startY + 10, { align: 'center' });
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Data: ${new Date().toLocaleDateString('pt-BR')}`, 105, startY + 16, { align: 'center' });
// Photo Positioning - Top Right (Standard 3x4cm = 30x40mm)
const photoX = 155;
const photoY = startY + 25;
const photoW = 30;
const photoH = 40;
if (student.photo) {
await addStudentPhoto3x4(doc, student.photo, photoX, photoY);
}
// Border around photo area
doc.setDrawColor(0);
doc.setLineWidth(0.1);
doc.rect(photoX, photoY, photoW, photoH);
if (!student.photo) {
doc.setFontSize(7);
doc.setTextColor(150);
doc.text('FOTO 3X4', photoX + 15, photoY + 20, { align: 'center' });
}
let currentY = startY + 30;
const labelX = 20;
// 1. Dados do Aluno
doc.setFontSize(12);
doc.setTextColor(0);
doc.setFont('helvetica', 'bold');
doc.text('Dados do Aluno', labelX, currentY);
currentY += 8;
doc.setFontSize(10);
const drawField = (label: string, value: string, y: number) => {
doc.setFont('helvetica', 'normal');
doc.text(`${label}: ${value || '-'}`, labelX, y);
return y + 6;
};
currentY = drawField('Nº Matrícula', student.enrollmentNumber || 'Não gerado', currentY);
currentY = drawField('Nome', student.name, currentY);
currentY = drawField('CPF', student.cpf, currentY);
currentY = drawField('RG', student.rg, currentY);
currentY = drawField('Data de Nascimento', student.birthDate ? student.birthDate.split('-').reverse().join('/') : '', currentY);
currentY = drawField('Email', student.email, currentY);
currentY = drawField('Telefone', student.phone, currentY);
currentY += 4;
// 2. Endereço
doc.setFontSize(11);
doc.setFont('helvetica', 'bold');
doc.text('Endereço', labelX, currentY);
currentY += 6;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`${student.addressStreet || ''}${student.addressNumber ? `, ${student.addressNumber}` : ''} - ${student.addressNeighborhood || ''}`, labelX, currentY);
currentY += 6;
doc.text(`${student.addressCity || ''} - ${student.addressState || ''} CEP: ${student.addressZip || ''}`, labelX, currentY);
currentY += 10;
// 3. Dados do Curso
doc.setFontSize(11);
doc.setFont('helvetica', 'bold');
doc.text('Dados do Curso', labelX, currentY);
currentY += 8;
doc.setFontSize(10);
currentY = drawField('Curso', course?.name || 'Não vinculado', currentY);
currentY = drawField('Turma', cls?.name || 'Não atribuída', currentY);
currentY = drawField('Horário', cls?.schedule || 'N/A', currentY);
currentY = drawField('Professor', cls?.teacher || 'N/A', currentY);
currentY = drawField('Data Matrícula', student.registrationDate ? student.registrationDate.split('T')[0].split('-').reverse().join('/') : '', currentY);
currentY += 10;
// 4. Termos e Condições
doc.setFontSize(11);
doc.setFont('helvetica', 'bold');
doc.text('Termos e Condições', labelX, currentY);
currentY += 8;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
const termsText = "Declaro que as informações acima são verdadeiras e assumo a responsabilidade pelo pagamento das mensalidades escolares conforme contrato de prestação de serviços educacionais.";
const splitTerms = doc.splitTextToSize(termsText, 170);
doc.text(splitTerms, labelX, currentY);
// Footer - Signatures
const pageHeight = doc.internal.pageSize.height;
const signer = getSignerInfo(student);
doc.setDrawColor(0);
doc.setLineWidth(0.2);
// Signer Signature
const sigY = pageHeight - 45;
doc.line(20, sigY, 90, sigY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.text(signer.name.toUpperCase(), 55, sigY + 5, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.text(`CPF: ${signer.cpf || '---'}`, 55, sigY + 9, { align: 'center' });
doc.text(signer.label, 55, sigY + 13, { align: 'center' });
// School Signature
const director = getDirectorInfo(schoolData);
const dirName = director ? director.name.toUpperCase() : schoolData.profile.name.toUpperCase();
const dirDoc = director ? `CPF: ${director.cpf}` : `CNPJ: ${schoolData.profile.cnpj || '---'}`;
const dirRole = director ? director.role : 'Assinatura da Escola';
doc.line(120, sigY, 190, sigY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.text(dirName, 155, sigY + 5, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.text(dirDoc, 155, sigY + 9, { align: 'center' });
doc.text(dirRole, 155, sigY + 13, { align: 'center' });
doc.save(`ficha_matricula_${student.name.replace(/\s+/g, '_').toLowerCase()}.pdf`);
},
generateStudentHistoryPDF: async (student: Student, schoolData: SchoolData) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
const payments = schoolData.payments.filter(p => p.studentId === student.id);
const contracts = schoolData.contracts.filter(c => c.studentId === student.id);
doc.setFontSize(16);
doc.setTextColor(0);
doc.text(`Histórico Acadêmico e Financeiro: ${student.name}`, 105, startY + 5, { align: 'center' });
doc.setFontSize(12);
doc.setTextColor(0);
doc.text('Contratos Ativos', 20, startY + 20);
doc.autoTable({
startY: startY + 25,
margin: { top: 45 },
didDrawPage: async (data: any) => {
if (data.pageNumber > 1) await addHeader(doc, schoolData);
},
head: [['Título', 'Data Emissão']],
body: contracts.map(c => [
c.title,
c.createdAt ? c.createdAt.split('T')[0].split('-').reverse().join('/') : ''
]),
headStyles: { fillColor: [0, 0, 0] }
});
const nextY = (doc as any).lastAutoTable.finalY + 15;
doc.setFontSize(12);
doc.text('Histórico de Pagamentos', 20, nextY);
doc.autoTable({
startY: nextY + 5,
margin: { top: 45 },
didDrawPage: async (data: any) => {
if (data.pageNumber > 1) await addHeader(doc, schoolData);
},
head: [['Descrição', 'Vencimento', 'Valor', 'Status']],
body: payments.map(p => [
p.description || (p.type === 'registration' ? 'Matrícula' : 'Mensalidade'),
p.dueDate ? p.dueDate.split('T')[0].split('-').reverse().join('/') : '',
`R$ ${p.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`,
p.status === 'paid' ? 'Pago' : p.status === 'overdue' ? 'Atrasado' : 'Pendente'
]),
headStyles: { fillColor: [0, 0, 0] }
});
doc.save(`historico_${student.name.replace(/\s+/g, '_').toLowerCase()}.pdf`);
},
generatePaymentReceiptPDF: async (payment: Payment, student: Student, schoolData: SchoolData) => {
const doc = new jsPDF() as any;
doc.setDrawColor(0);
doc.setLineWidth(0.5);
doc.rect(10, 10, 190, 140); // Border
const profile = schoolData.profile;
if (schoolData.logo) {
await addImageProportional(doc, schoolData.logo, 20, 15, 20, 20);
}
doc.setFontSize(12);
doc.setTextColor(0);
doc.text(profile.name, 45, 18);
doc.setFontSize(8);
doc.text(`CNPJ: ${profile.cnpj || '---'}`, 45, 22);
doc.text(profile.address || '', 45, 26);
doc.setFontSize(16);
doc.setTextColor(0);
doc.text('RECIBO DE PAGAMENTO', 105, 45, { align: 'center' });
doc.setFontSize(9);
doc.setTextColor(0);
doc.text(`Nº do Documento: ${payment.id.substring(0, 8).toUpperCase()}`, 150, 55);
doc.setFontSize(11);
doc.setTextColor(0);
doc.text(`Recebemos de: ${student.name}`, 20, 70);
doc.text(`CPF: ${student.cpf || '---'}`, 20, 76);
doc.text(`A quantia de: R$ ${payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`, 20, 85);
const typeLabel = payment.type === 'registration' ? 'Taxa de Matrícula' :
payment.type === 'monthly' ? 'Mensalidade do Curso' : 'Outros Serviços';
doc.text(`Referente a: ${typeLabel} ${payment.description ? `(${payment.description})` : ''}`, 20, 95);
doc.text(`Data de Vencimento: ${payment.dueDate ? payment.dueDate.split('T')[0].split('-').reverse().join('/') : ''}`, 20, 105);
if (payment.status === 'paid' && payment.paidDate) {
doc.setFontSize(12);
doc.setTextColor(0);
doc.text(`PAGO EM: ${payment.paidDate}`, 105, 120, { align: 'center' });
}
doc.setTextColor(0);
doc.setFontSize(9);
doc.text('_________________________________', 105, 140, { align: 'center' });
doc.text('Assinatura / Carimbo', 105, 145, { align: 'center' });
doc.save(`recibo_${student.name.replace(/\s+/g, '_').toLowerCase()}_${payment.id.substring(0, 4)}.pdf`);
},
generateContractPDF: async (contract: Contract, student: Student, schoolData: SchoolData) => {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
}) as any;
let currentY = await addHeader(doc, schoolData);
// Title
doc.setFontSize(14);
doc.setTextColor(0);
doc.setFont('helvetica', 'bold');
doc.text('CONTRATO DE PRESTAÇÃO DE SERVIÇOS EDUCACIONAIS', 105, currentY, { align: 'center' });
currentY += 10;
// Contract Header Info
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`DATA DE EMISSÃO: ${contract.createdAt ? contract.createdAt.split('T')[0].split('-').reverse().join('/') : ''}`, 20, currentY);
currentY += 6;
doc.text(`CONTRATANTE: ${student.name.toUpperCase()}`, 20, currentY);
currentY += 6;
doc.text(`CPF: ${student.cpf || '---'}`, 20, currentY);
currentY += 10;
// Draw Justified Content with Pagination
const margin = 20; // 2cm
const pageWidth = doc.internal.pageSize.width;
const maxWidth = pageWidth - (margin * 2);
const lineHeight = 5.5; // 1.5 spacing approx for 10pt font
currentY = await drawContractText(doc, contract.content, margin, currentY, maxWidth, lineHeight, schoolData);
// Signatures
const pageHeight = doc.internal.pageSize.height;
const signer = getSignerInfo(student);
// Check if signatures fit on current page (need about 45mm to keep them together without breaking)
if (currentY > pageHeight - 45) {
doc.addPage();
currentY = await addHeader(doc, schoolData);
currentY += 10;
} else {
currentY += 25; // Extra space before signatures
}
// Signature Block - Unbreakable, Side by Side
doc.setDrawColor(0);
doc.setLineWidth(0.2);
const sigY = currentY;
// Signer Signature (Left Column)
doc.line(20, sigY, 90, sigY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.text(signer.name.toUpperCase(), 55, sigY + 5, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.text(`CPF: ${signer.cpf || '---'}`, 55, sigY + 9, { align: 'center' });
doc.text(signer.label, 55, sigY + 13, { align: 'center' });
// School Signature (Right Column)
const director = getDirectorInfo(schoolData);
const dirName = director ? director.name.toUpperCase() : schoolData.profile.name.toUpperCase();
const dirDoc = director ? `CPF: ${director.cpf}` : `CNPJ: ${schoolData.profile.cnpj || '---'}`;
const dirRole = director ? director.role : 'Assinatura da Escola';
doc.line(120, sigY, 190, sigY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.text(dirName, 155, sigY + 5, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.text(dirDoc, 155, sigY + 9, { align: 'center' });
doc.text(dirRole, 155, sigY + 13, { align: 'center' });
// Add Page Numbers to all pages
addPageNumbers(doc);
doc.save(`contrato_${contract.title.replace(/\s+/g, '_').toLowerCase()}.pdf`);
},
generateClassListPDF: async (cls: Class, schoolData: SchoolData) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
const course = schoolData.courses.find(c => c.id === cls.courseId);
const students = schoolData.students.filter(s => s.classId === cls.id);
doc.setFontSize(16);
doc.setTextColor(0);
doc.text(`Relatório de Turma: ${cls.name}`, 105, startY + 5, { align: 'center' });
doc.setFontSize(10);
doc.setTextColor(0);
doc.text(`Curso: ${course?.name || 'N/A'}`, 20, startY + 15);
doc.text(`Professor: ${cls.teacher}`, 20, startY + 22);
doc.text(`Horário: ${cls.schedule}`, 20, startY + 29);
doc.autoTable({
startY: startY + 35,
margin: { top: 45 },
didDrawPage: async (data: any) => {
if (data.pageNumber > 1) await addHeader(doc, schoolData);
},
head: [['Nº', 'Nome do Aluno', 'Telefone', 'Status']],
body: students.map((s, idx) => [
idx + 1,
s.name,
s.phone,
s.status === 'active' ? 'Ativo' : 'Inativo'
]),
headStyles: { fillColor: [0, 0, 0] }
});
doc.save(`turma_${cls.name.replace(/\s+/g, '_').toLowerCase()}.pdf`);
},
generateStudentListPDF: async (schoolData: SchoolData) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
doc.setFontSize(16);
doc.setTextColor(0);
doc.text('Relatório Geral de Alunos', 105, startY + 5, { align: 'center' });
doc.autoTable({
startY: startY + 15,
margin: { top: 45 },
didDrawPage: async (data: any) => {
if (data.pageNumber > 1) await addHeader(doc, schoolData);
},
head: [['Nome', 'CPF', 'Email', 'Turma', 'Status']],
body: schoolData.students.map(s => {
const cls = schoolData.classes.find(c => c.id === s.classId);
return [
s.name,
s.cpf || '-',
s.email,
cls?.name || '-',
s.status === 'active' ? 'Ativo' : 'Inativo'
];
}),
headStyles: { fillColor: [0, 0, 0] }
});
doc.save(`lista_alunos_${new Date().toISOString().split('T')[0]}.pdf`);
},
generateFullSchoolReportPDF: async (schoolData: SchoolData) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
doc.setFontSize(18);
doc.setTextColor(0);
doc.text('Relatório Consolidado', 105, startY + 5, { align: 'center' });
doc.setFontSize(12);
doc.text('Visão Geral', 20, startY + 20);
doc.setFontSize(10);
doc.text(`Total Alunos: ${schoolData.students.length}`, 20, startY + 28);
doc.text(`Alunos Ativos: ${schoolData.students.filter(s => s.status === 'active').length}`, 20, startY + 34);
doc.text(`Turmas Ativas: ${schoolData.classes.length}`, 20, startY + 40);
doc.autoTable({
startY: startY + 50,
margin: { top: 45 },
didDrawPage: async (data: any) => {
if (data.pageNumber > 1) await addHeader(doc, schoolData);
},
head: [['Alunos', 'Turmas', 'Financeiro Pago', 'Pendente']],
body: [[
schoolData.students.length,
schoolData.classes.length,
`R$ ${schoolData.payments.filter(p => p.status === 'paid').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}`,
`R$ ${schoolData.payments.filter(p => p.status !== 'paid').reduce((sum, p) => sum + p.amount, 0).toFixed(2)}`
]],
headStyles: { fillColor: [0, 0, 0] }
});
doc.save(`relatorio_geral_${new Date().toISOString().split('T')[0]}.pdf`);
},
generateCancellationTermPDF: async (student: Student, schoolData: SchoolData, cancellationReason: string) => {
const doc = new jsPDF() as any;
const startY = await addHeader(doc, schoolData);
const cls = schoolData.classes.find(c => c.id === student.classId);
const course = schoolData.courses.find(c => c.id === cls?.courseId);
// Title
doc.setFontSize(16);
doc.setTextColor(0);
doc.setFont('helvetica', 'bold');
doc.text('TERMO DE CANCELAMENTO DE MATRÍCULA', 105, startY + 10, { align: 'center' });
let currentY = startY + 25;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('Escola:', 20, currentY);
doc.setFont('helvetica', 'normal');
doc.text(schoolData.profile.name || 'Microtec Informática Cursos', 38, currentY);
currentY += 15;
// Student Data
doc.setFont('helvetica', 'bold');
doc.text('Dados do Aluno:', 20, currentY);
currentY += 8;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text(`Nome: ${student.name} | CPF: ${student.cpf || 'Não informado'}`, 20, currentY);
currentY += 6;
doc.text(`Curso: ${course?.name || 'Não informado'} | Turma: ${cls?.name || 'Não informado'}`, 20, currentY);
currentY += 12;
// Guardian Data
if (student.guardianName) {
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('Dados do Responsável (se menor de idade):', 20, currentY);
currentY += 8;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text(`Nome: ${student.guardianName} | CPF: ${student.guardianCpf || 'Não informado'}`, 20, currentY);
currentY += 12;
}
// Reason
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('Motivo do Cancelamento:', 20, currentY);
currentY += 8;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
const splitReason = doc.splitTextToSize(cancellationReason, 170);
doc.text(splitReason, 20, currentY);
currentY += (splitReason.length * 6) + 10;
// Term Text
const termText1 = 'Pelo presente termo, o(a) aluno(a) ou seu responsável legal acima qualificado, solicita formalmente o CANCELAMENTO DA MATRÍCULA no curso especificado.';
const termText2 = 'Declara estar ciente de que o cancelamento encerra o vínculo educacional a partir desta data, não isentando o contratante de eventuais pendências financeiras adquiridas e vencidas até o presente momento, conforme contrato de prestação de serviços educacionais assinado no ato da matrícula.';
const splitTerm1 = doc.splitTextToSize(termText1, 170);
doc.text(splitTerm1, 20, currentY);
currentY += (splitTerm1.length * 6) + 4;
const splitTerm2 = doc.splitTextToSize(termText2, 170);
doc.text(splitTerm2, 20, currentY);
currentY += (splitTerm2.length * 6) + 20;
// Date and Signatures
const dateStr = new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' });
doc.text(`Redenção - CE, ${dateStr}.`, 20, currentY);
currentY += 30;
doc.line(20, currentY, 90, currentY);
doc.line(120, currentY, 190, currentY);
currentY += 5;
doc.setFontSize(10);
doc.text('Assinatura do Aluno ou Responsável Legal', 55, currentY, { align: 'center' });
doc.text(`${schoolData.profile.name || 'Microtec Informática Cursos'} (Administração)`, 155, currentY, { align: 'center' });
doc.save(`termo_cancelamento_${student.name.replace(/\s+/g, '_')}.pdf`);
}
};

View File

@ -0,0 +1,98 @@
/**
* ============================================================
* SERVIÇO DE STORAGE MinIO S3-Compatible (Self-Hosted)
* Substitui todas as chamadas supabase.storage do sistema
* ============================================================
*/
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
const MINIO_PORT = process.env.MINIO_PORT || '9000';
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin';
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'MiniO2026!Seguro';
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || 'http://localhost:9000';
// Cliente S3 apontando para o MinIO interno
const s3Client = new S3Client({
endpoint: `http://${MINIO_ENDPOINT}:${MINIO_PORT}`,
region: 'us-east-1', // MinIO ignora, mas o SDK exige
credentials: {
accessKeyId: MINIO_ACCESS_KEY,
secretAccessKey: MINIO_SECRET_KEY,
},
forcePathStyle: true, // Obrigatório para MinIO
});
/**
* Upload de arquivo para o MinIO
* @param {string} bucket - Nome do bucket (ex: 'logos', 'fotos-alunos', 'carnes')
* @param {string} fileName - Nome do arquivo (ex: 'logo_123.webp')
* @param {Buffer} fileBuffer - Conteúdo do arquivo
* @param {string} contentType - MIME type (ex: 'image/webp')
* @returns {string} URL pública do arquivo
*/
export async function uploadFile(bucket, fileName, fileBuffer, contentType) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: fileName,
Body: fileBuffer,
ContentType: contentType,
});
await s3Client.send(command);
// Retorna a URL pública (MinIO com política de download anônimo)
return `${MINIO_PUBLIC_URL}/${bucket}/${fileName}`;
}
/**
* Gera a URL pública de um arquivo existente
*/
export function getPublicUrl(bucket, fileName) {
return `${MINIO_PUBLIC_URL}/${bucket}/${fileName}`;
}
/**
* Upload de logo da escola
*/
export async function uploadLogo(fileBuffer, contentType) {
const ext = contentType.includes('webp') ? 'webp' : 'png';
const fileName = `logo_${Date.now()}.${ext}`;
return uploadFile('logos', fileName, fileBuffer, contentType);
}
/**
* Upload de foto de aluno
*/
export async function uploadStudentPhoto(fileBuffer, contentType) {
const ext = contentType.includes('webp') ? 'webp' : contentType.split('/')[1] || 'jpg';
const fileName = `student_${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
return uploadFile('fotos-alunos', fileName, fileBuffer, contentType);
}
/**
* Upload de carnê PDF
*/
export async function uploadCarne(fileName, pdfBuffer) {
return uploadFile('carnes', fileName, pdfBuffer, 'application/pdf');
}
/**
* Upload de imagem de prova
*/
export async function uploadExamImage(fileBuffer, contentType) {
const ext = contentType.split('/')[1] || 'webp';
const fileName = `exam_${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
return uploadFile('exames', fileName, fileBuffer, contentType);
}
/**
* Upload de atestado/justificativa
*/
export async function uploadAtestado(fileBuffer, contentType) {
const ext = contentType.split('/')[1] || 'jpg';
const fileName = `atestado_${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
return uploadFile('atestados', fileName, fileBuffer, contentType);
}
export { s3Client };

View File

@ -0,0 +1,134 @@
/**
* ============================================================
* SERVIÇO DE STORAGE SELF-HOSTED (API HTTP)
* ============================================================
* Substitui @supabase/supabase-js Storage por chamadas HTTP
* à API do EduManager Self-Hosted.
*
* NENHUMA tela, componente ou design foi alterado.
* ============================================================
*/
// Detecta se está configurado (sempre true no self-hosted)
export const isSupabaseConfigured = () => true;
// Objeto dummy para compatibilidade com imports legados
export const supabase = {
from: () => { throw new Error('Self-Hosted: Use as funções HTTP em vez de supabase.from()'); },
storage: { from: () => { throw new Error('Self-Hosted: Use as funções de upload HTTP'); } },
};
/**
* Upload de foto de perfil (usuário admin)
*/
export const uploadProfilePicture = async (userId: string, file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('photo', file);
formData.append('userId', userId);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error) {
console.error('Erro ao fazer upload de foto de perfil:', error);
return null;
}
};
/**
* Upload de logo da escola
*/
export const uploadLogo = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('logo', file);
const response = await fetch('/api/upload/logo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error) {
console.error('Erro ao fazer upload de logo:', error);
return null;
}
};
/**
* Upload de imagem de prova
*/
export const uploadExamImage = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('photo', file);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error: any) {
console.error('Erro ao fazer upload de imagem de prova:', error);
throw error;
}
};
/**
* Upload de foto de aluno (converte base64 para File e faz upload)
*/
export const uploadStudentPhoto = async (photoData: string): Promise<string | null> => {
// Se já é uma URL, retorna diretamente
if (!photoData || photoData.startsWith('http')) {
return photoData || null;
}
// Se não é base64, retorna null
if (!photoData.startsWith('data:image')) {
return null;
}
try {
// Converter base64 para Blob
const [header, base64Data] = photoData.split(',');
const mimeMatch = header.match(/:(.*?);/);
if (!mimeMatch) return null;
const mimeType = mimeMatch[1];
const byteString = atob(base64Data);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeType });
// Criar FormData e enviar
const formData = new FormData();
const ext = mimeType.split('/')[1] || 'webp';
formData.append('photo', blob, `student-photo.${ext}`);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error: any) {
console.error('Erro ao fazer upload de foto do aluno:', error);
return null;
}
};

View File

@ -0,0 +1,134 @@
/**
* ============================================================
* SERVIÇO DE STORAGE SELF-HOSTED (API HTTP)
* ============================================================
* Substitui @supabase/supabase-js Storage por chamadas HTTP
* à API do EduManager Self-Hosted.
*
* NENHUMA tela, componente ou design foi alterado.
* ============================================================
*/
// Detecta se está configurado (sempre true no self-hosted)
export const isSupabaseConfigured = () => true;
// Objeto dummy para compatibilidade com imports legados
export const supabase = {
from: () => { throw new Error('Self-Hosted: Use as funções HTTP em vez de supabase.from()'); },
storage: { from: () => { throw new Error('Self-Hosted: Use as funções de upload HTTP'); } },
};
/**
* Upload de foto de perfil (usuário admin)
*/
export const uploadProfilePicture = async (userId: string, file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('photo', file);
formData.append('userId', userId);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error) {
console.error('Erro ao fazer upload de foto de perfil:', error);
return null;
}
};
/**
* Upload de logo da escola
*/
export const uploadLogo = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('logo', file);
const response = await fetch('/api/upload/logo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error) {
console.error('Erro ao fazer upload de logo:', error);
return null;
}
};
/**
* Upload de imagem de prova
*/
export const uploadExamImage = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('photo', file);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error: any) {
console.error('Erro ao fazer upload de imagem de prova:', error);
throw error;
}
};
/**
* Upload de foto de aluno (converte base64 para File e faz upload)
*/
export const uploadStudentPhoto = async (photoData: string): Promise<string | null> => {
// Se já é uma URL, retorna diretamente
if (!photoData || photoData.startsWith('http')) {
return photoData || null;
}
// Se não é base64, retorna null
if (!photoData.startsWith('data:image')) {
return null;
}
try {
// Converter base64 para Blob
const [header, base64Data] = photoData.split(',');
const mimeMatch = header.match(/:(.*?);/);
if (!mimeMatch) return null;
const mimeType = mimeMatch[1];
const byteString = atob(base64Data);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeType });
// Criar FormData e enviar
const formData = new FormData();
const ext = mimeType.split('/')[1] || 'webp';
formData.append('photo', blob, `student-photo.${ext}`);
const response = await fetch('/api/upload/student-photo', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload falhou');
const data = await response.json();
return data.url;
} catch (error: any) {
console.error('Erro ao fazer upload de foto do aluno:', error);
return null;
}
};

View File

@ -0,0 +1,31 @@
-- SQL Schema for EduManager Employee Module and User Enhancements
-- 1. Employee Categories Table
CREATE TABLE IF NOT EXISTS categorias_funcionarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. Employees Table
CREATE TABLE IF NOT EXISTS funcionarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
nome TEXT NOT NULL,
cpf TEXT UNIQUE NOT NULL,
telefone TEXT,
email TEXT,
data_admissao DATE,
categoria_id UUID REFERENCES categorias_funcionarios(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. Storage Bucket for Profile Pictures
-- Note: You need to create the bucket 'edumanager-assets' in the Supabase Dashboard
-- and set its policy to public or authenticated as needed.
-- Example Policies for Storage:
-- CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'edumanager-assets');
-- CREATE POLICY "Authenticated Upload" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'edumanager-assets' AND auth.role() = 'authenticated');
-- 4. Update school_data table if needed (EduManager uses a single JSON blob for most data)
-- The application logic handles the JSON structure updates automatically.

29
manager/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

316
manager/types.ts Normal file
View File

@ -0,0 +1,316 @@
export interface User {
id: string;
name: string; // Username
displayName?: string; // Real name
photoURL?: string; // Profile photo URL
password: string; // In a real app, this should be hashed
cpf: string;
role?: 'admin' | 'user';
}
export interface Course {
id: string;
name: string;
duration: string; // Ex: "12 meses"
durationMonths: number; // Campo numérico para cálculos
registrationFee: number;
monthlyFee: number;
description: string;
finePercentage?: number;
interestPercentage?: number;
}
export interface Student {
id: string;
name: string;
email: string;
phone: string;
birthDate: string; // Formato esperado: YYYY-MM-DD
cpf: string;
rg?: string; // Novo campo
rgIssueDate?: string; // Novo campo: YYYY-MM-DD
guardianName?: string;
guardianPhone?: string;
guardianCpf?: string;
guardianBirthDate?: string; // Novo campo
classId: string;
status: 'active' | 'inactive' | 'cancelled';
cancellationReason?: string;
registrationDate: string;
photo?: string; // Base64 image
faceDescriptor?: number[]; // Array of numbers for face recognition
// Address fields
addressZip?: string;
addressStreet?: string;
addressNumber?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
discount?: number;
hasGuardian?: boolean;
contractTemplateId?: string; // Vínculo com o modelo de contrato
enrollmentNumber?: string; // Número de matrícula (login do portal do aluno)
portalPassword?: string; // Senha do portal do aluno (padrão: 6 primeiros dígitos do CPF)
}
export interface Class {
id: string;
name: string;
courseId: string; // Linked to Course.id
teacher: string;
schedule: string;
scheduleDay?: string; // NOVO: 0 (Domingo) a 6 (Sábado)
maxStudents: number;
startDate?: string;
endDate?: string;
defaultStartTime?: string;
defaultEndTime?: string;
}
export interface Lesson {
id: string;
classId: string;
date: string; // ISO Date YYYY-MM-DD
startTime?: string; // HH:mm
endTime?: string; // HH:mm
status: 'scheduled' | 'cancelled' | 'completed' | 'rescheduled';
type: 'regular' | 'reposicao' | 'extra';
cancelReason?: string;
originalLessonId?: string; // Se type === 'reposicao'
}
export interface Notification {
id: string;
studentId: string;
title: string;
message: string;
read: boolean;
createdAt: string; // ISO string
attachment?: string;
}
export interface Payment {
id: string;
studentId: string;
contractId?: string; // Vínculo com o contrato
amount: number;
discount?: number; // Valor do desconto aplicado
discountType?: 'fixed' | 'percentage'; // Tipo do desconto
lateFee?: number; // Multa por atraso
interest?: number; // Juros por atraso
dueDate: string;
status: 'pending' | 'paid' | 'overdue';
paidDate?: string;
type: 'monthly' | 'registration' | 'other';
installmentNumber?: number;
totalInstallments?: number;
description?: string;
asaasPaymentId?: string;
asaasPaymentUrl?: string;
installmentId?: string;
}
export interface Contract {
id: string;
studentId: string;
title: string;
content: string;
createdAt: string;
}
export interface SchoolProfile {
id: string;
name: string;
address: string;
city: string;
state: string;
zip: string;
cnpj: string;
phone: string;
email: string;
type: 'matriz' | 'filial';
}
export interface ContractTemplate {
id: string;
name: string;
content: string;
}
export interface TextOverlay {
id: string;
text: string;
x: number;
y: number;
fontSize: number;
color: string;
}
export interface Certificate {
id: string;
studentId: string;
description?: string; // Descrição ou título do certificado
frontImage: string; // Base64
backImage?: string; // Base64 (Opcional)
issueDate: string;
frontOverlays: TextOverlay[];
backOverlays: TextOverlay[];
}
export interface Attendance {
id: string;
studentId: string;
classId: string;
date: string; // ISO String
photo?: string; // Base64 (Optional for absences)
verified: boolean;
type?: 'presence' | 'absence';
justification?: string;
justificationAccepted?: boolean;
}
export interface CertificateTemplate {
id: string;
name: string;
frontImage: string;
backImage?: string;
frontOverlays: TextOverlay[];
backOverlays: TextOverlay[];
}
export interface Subject {
id: string;
name: string;
}
export interface Period {
id: string;
name: string;
}
export interface Grade {
id: string;
studentId: string;
subjectId: string;
value: number;
period: string; // e.g., "1º Bimestre", "Final"
}
export interface Handout {
id: string;
name: string;
price: number;
description?: string;
finePercentage?: number;
interestPercentage?: number;
}
export interface HandoutDelivery {
id: string;
studentId: string;
handoutId: string;
deliveryStatus: 'pending' | 'delivered';
paymentStatus: 'pending' | 'paid';
deliveryDate?: string;
paymentDate?: string;
asaasPaymentId?: string;
asaasPaymentUrl?: string;
}
export interface EmployeeCategory {
id: string;
name: string;
}
export interface Employee {
id: string;
name: string;
cpf: string;
phone: string;
email: string;
admissionDate: string;
categoryId: string;
}
export interface Question {
id: string;
text: string;
imageUrl?: string;
options: string[];
correctOptionIndex: number;
}
export interface Exam {
id: string;
classId: string;
subjectId?: string; // Vincula à disciplina do Boletim Escolar
periodId?: string; // Vincula ao período do Boletim Escolar
title: string;
durationMinutes: number;
status: 'draft' | 'published';
questions: Question[];
}
export interface SchoolData {
users: User[];
courses: Course[];
students: Student[];
classes: Class[];
payments: Payment[];
contracts: Contract[];
contractTemplates?: ContractTemplate[];
certificates: Certificate[];
certificateTemplates?: CertificateTemplate[];
attendance: Attendance[];
subjects: Subject[];
periods: Period[];
grades: Grade[];
handouts?: Handout[];
handoutDeliveries?: HandoutDelivery[];
employees?: Employee[];
employeeCategories?: EmployeeCategory[];
lessons?: Lesson[];
notifications?: Notification[];
exams?: Exam[];
profiles: SchoolProfile[];
profile: SchoolProfile;
logo?: string;
lastUpdated?: string;
evolutionConfig?: {
apiUrl: string;
instanceName: string;
apiKey: string;
};
messageTemplates?: {
boletoGerado: string;
pagamentoConfirmado: string;
boletoVencido: string;
felizAniversario?: string;
cobrancaCancelada?: string;
cobrancaAtualizada?: string;
automationRules: {
sendOnDueDate: boolean;
sendDaysAfter: string;
repeatEveryDays: string;
};
};
}
export enum View {
Dashboard = 'dashboard',
Courses = 'courses',
Students = 'students',
Classes = 'classes',
Finance = 'finance',
Contracts = 'contracts',
Certificates = 'certificates',
Attendance = 'attendance',
AttendanceQuery = 'attendance_query',
ReportCard = 'report_card',
Handouts = 'handouts',
Employees = 'employees',
Settings = 'settings',
Users = 'users',
Messages = 'messages',
Exams = 'exams'
}

11
manager/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string
readonly VITE_SUPABASE_KEY: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

31
manager/vite.config.ts Normal file
View File

@ -0,0 +1,31 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.VITE_SUPABASE_URL': JSON.stringify(env.VITE_SUPABASE_URL),
'process.env.VITE_SUPABASE_KEY': JSON.stringify(env.VITE_SUPABASE_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

4
portal/.env.example Normal file
View File

@ -0,0 +1,4 @@
PORT=3001
VITE_SUPABASE_URL=https://xxxx.supabase.co
VITE_SUPABASE_KEY=eyJhb...
JWT_SECRET=uma-chave-secreta-forte-aqui

22
portal/.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Dependencies
node_modules/
# Build
dist/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*

17
portal/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production Stage ----
FROM node:22-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "server.js"]

48
portal/check_table.js Normal file
View File

@ -0,0 +1,48 @@
// Script para criar a tabela provas_submissoes no Supabase
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(
'https://ekbuvcjsfcczviqqlfit.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us'
);
async function createTable() {
// Tenta inserir e ver o erro para confirmar que a tabela não existe
const { data, error } = await supabase
.from('provas_submissoes')
.select('*')
.limit(1);
if (error) {
console.log('❌ Tabela provas_submissoes NÃO existe.');
console.log('Erro:', error.message);
console.log('\n📋 Execute o seguinte SQL no painel do Supabase (SQL Editor):');
console.log('='.repeat(60));
console.log(`
CREATE TABLE provas_submissoes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
aluno_id TEXT NOT NULL,
exam_id TEXT NOT NULL,
total_questions INTEGER NOT NULL,
correct_count INTEGER NOT NULL,
wrong_count INTEGER NOT NULL,
percentage NUMERIC(5,2) NOT NULL,
final_score NUMERIC(4,2) NOT NULL,
answers_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Permitir acesso via anon key
ALTER TABLE provas_submissoes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all for anon" ON provas_submissoes
FOR ALL USING (true) WITH CHECK (true);
`);
console.log('='.repeat(60));
} else {
console.log('✅ Tabela provas_submissoes JÁ existe!');
console.log('Dados:', data);
}
}
createTable();

32
portal/debug_supabase.js Normal file
View File

@ -0,0 +1,32 @@
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(
'https://ekbuvcjsfcczviqqlfit.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us'
);
async function checkStructure() {
console.log('--- Analisando Supabase ---');
// Tenta ler as colunas usando a API do PostgREST
const { data, error } = await supabase
.from('provas_submissoes')
.select('*')
.limit(1);
if (error) {
console.error('❌ Erro ao acessar tabela:', error.message);
if (error.message.includes('column')) {
console.log('Dica: Alguma coluna solicitada não existe.');
}
} else {
console.log('✅ Tabela acessível!');
if (data.length > 0) {
console.log('Colunas encontradas no primeiro registro:', Object.keys(data[0]));
} else {
console.log('Tabela está vazia, mas existe.');
}
}
}
checkStructure();

33
portal/docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
version: '3.8'
services:
portalaluno:
image: portalaluno:latest
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_KEY=${VITE_SUPABASE_KEY}
- JWT_SECRET=${JWT_SECRET}
- PORT=3001
networks:
- network_public
- default
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.portalaluno.rule=Host(`aluno.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.portalaluno.entrypoints=websecure"
- "traefik.http.routers.portalaluno.tls=true"
- "traefik.http.routers.portalaluno.tls.certresolver=leresolver"
- "traefik.http.services.portalaluno.loadbalancer.server.port=3001"
- "traefik.docker.network=network_public"
networks:
network_public:
external: true
default:
driver: overlay

17
portal/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Portal do Aluno - Acesse suas informações acadêmicas e financeiras" />
<title>Portal do Aluno</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2783
portal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
portal/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "portal-aluno",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "node server.js",
"build": "vite build",
"preview": "vite preview",
"start": "node server.js"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1"
},
"dependencies": {
"@supabase/supabase-js": "^2.101.0",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"pg": "^8.13.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
}
}

View File

@ -0,0 +1,358 @@
# PROMPT COMPLETO — Portal do Aluno (EduManager Student Portal)
> **Copie este prompt inteiro e cole em uma nova conversa para criar o projeto do zero.**
---
## Objetivo
Atue como um Desenvolvedor Sênior Full-Stack. Crie um projeto NOVO e SEPARADO chamado **"Portal do Aluno"** — uma aplicação web onde os alunos matriculados no sistema **EduManager** podem fazer login e visualizar seus dados acadêmicos e financeiros.
Este portal **NÃO faz parte do código do EduManager**. É um projeto independente que **CONSOME os mesmos dados** do EduManager, lendo diretamente do mesmo banco de dados **Supabase**.
---
## Stack Tecnológica Obrigatória
- **Frontend:** React + TypeScript + Vite
- **Backend:** Node.js + Express (server.js)
- **Banco de Dados:** Supabase (PostgreSQL) — **o mesmo banco do EduManager, apenas leitura**
- **Estilização:** TailwindCSS
- **Deploy:** Docker (Dockerfile multi-stage) para rodar no **Portainer via Docker Swarm**
- **Porta:** 3001 (para não conflitar com o EduManager que roda na 3000)
---
## Arquitetura do Banco de Dados (Supabase — SOMENTE LEITURA)
O EduManager armazena TODOS os dados da escola em uma **única tabela** chamada `school_data` com uma **única linha** (id = 1). O campo `data` é um JSON gigante com a seguinte estrutura:
```typescript
// Tabela: school_data (id: 1, coluna "data" tipo JSONB)
{
students: Student[], // Lista de todos os alunos
classes: Class[], // Lista de turmas
courses: Course[], // Lista de cursos
payments: Payment[], // Lista de todos os pagamentos/cobranças
contracts: Contract[], // Contratos dos alunos
certificates: Certificate[], // Certificados emitidos
attendance: Attendance[], // Registros de presença
subjects: Subject[], // Disciplinas
grades: Grade[], // Notas dos alunos
profile: SchoolProfile, // Dados da escola (nome, logo, etc.)
logo?: string, // Logo da escola em base64
}
```
### Interface Student (campos relevantes para login):
```typescript
interface Student {
id: string; // UUID
name: string; // Nome completo
email: string;
phone: string;
birthDate: string; // YYYY-MM-DD
cpf: string; // Formato: 000.000.000-00
rg?: string;
classId: string; // ID da turma vinculada
status: 'active' | 'inactive' | 'cancelled';
registrationDate: string;
photo?: string; // Base64 da foto do aluno
addressZip?: string;
addressStreet?: string;
addressNumber?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
enrollmentNumber?: string; // ← LOGIN (formato: MAT-202600001)
portalPassword?: string; // ← SENHA (padrão: 6 primeiros dígitos do CPF)
discount?: number;
}
```
### Interface Payment (cobranças do aluno):
```typescript
interface Payment {
id: string;
studentId: string; // Relaciona com Student.id
amount: number;
discount?: number;
dueDate: string; // YYYY-MM-DD
status: 'pending' | 'paid' | 'overdue';
paidDate?: string;
type: 'monthly' | 'registration' | 'other';
installmentNumber?: number;
totalInstallments?: number;
description?: string;
asaasPaymentId?: string; // ID no Asaas
asaasPaymentUrl?: string; // URL do boleto no Asaas
}
```
### Outras Tabelas Auxiliares no Supabase:
**Tabela `alunos_cobrancas`** (cobranças sincronizadas com Asaas):
```sql
-- Colunas principais:
id (uuid), aluno_id (uuid), asaas_customer_id (text), asaas_payment_id (text),
asaas_installment_id (text), valor (numeric), vencimento (date),
link_boleto (text), link_carne (text), status (text), created_at (timestamptz)
```
### Interface Grade (notas):
```typescript
interface Grade {
id: string;
studentId: string;
subjectId: string;
value: number;
period: string; // Ex: "1º Bimestre"
}
```
### Interface Attendance (frequência):
```typescript
interface Attendance {
id: string;
studentId: string;
classId: string;
date: string; // ISO String
verified: boolean;
type?: 'presence' | 'absence';
justification?: string;
}
```
### Interface Class e Course:
```typescript
interface Class {
id: string;
name: string;
courseId: string;
teacher: string;
schedule: string;
}
interface Course {
id: string;
name: string;
duration: string;
monthlyFee: number;
}
```
### Interface Contract:
```typescript
interface Contract {
id: string;
studentId: string;
title: string;
content: string; // HTML do contrato
createdAt: string;
}
```
### Interface Certificate:
```typescript
interface Certificate {
id: string;
studentId: string;
description?: string;
issueDate: string;
}
```
### Interface SchoolProfile:
```typescript
interface SchoolProfile {
id: string;
name: string;
address: string;
city: string;
state: string;
cnpj: string;
phone: string;
email: string;
}
```
---
## Sistema de Autenticação
### Login:
- **Usuário:** Campo `enrollmentNumber` do aluno (ex: `MAT-202600001`)
- **Senha:** Campo `portalPassword` do aluno (padrão: 6 primeiros dígitos do CPF)
### Fluxo de Login no Backend:
1. Receber `enrollmentNumber` e `password` via POST `/api/portal/login`
2. Consultar a tabela `school_data` (id=1), pegar o JSON `data`
3. Buscar no array `data.students` o aluno cujo `enrollmentNumber` = input do usuário
4. Verificar se `portalPassword` === senha digitada
5. Se válido: gerar um JWT (jsonwebtoken) com `{ studentId, enrollmentNumber, name }` e retornar
6. Se inválido: retornar 401
### Middleware de Autenticação:
- Criar middleware `authMiddleware` que valida o JWT em todas as rotas `/api/portal/*`
- O JWT secret deve vir de `process.env.JWT_SECRET`
---
## Funcionalidades do Portal (Páginas)
### 1. Tela de Login
- Design moderno, escuro, com gradientes
- Logo da escola carregada dinamicamente do Supabase (campo `data.logo`)
- Campos: Nº de Matrícula + Senha
- Botão "Entrar"
- Mensagem de erro amigável se credenciais inválidas
### 2. Dashboard (Página Inicial pós-login)
- Saudação: "Olá, {nome do aluno}!"
- Foto do aluno (se tiver)
- Cards resumo:
- **Turma:** nome da turma e curso vinculado
- **Financeiro:** total de parcelas pendentes / valor total em aberto
- **Frequência:** porcentagem de presença
- **Próximo vencimento:** data e valor
### 3. Financeiro (Meus Boletos)
- Tabela listando TODOS os pagamentos do aluno (filtrados por `studentId`)
- Colunas: Descrição, Vencimento, Valor, Status (badges coloridos), Ação
- Botão "Ver Boleto" que abre o link do Asaas (`asaasPaymentUrl` ou buscar da tabela `alunos_cobrancas.link_boleto`)
- Filtros: Todos, Pendentes, Pagos, Atrasados
- Destaque visual para boletos atrasados (vermelho)
### 4. Notas / Boletim
- Buscar no array `data.grades` todas as notas onde `studentId` = aluno logado
- Buscar os nomes das disciplinas no array `data.subjects`
- Exibir em formato de tabela/boletim organizada por período (1º Bimestre, 2º Bimestre, etc.)
- Calcular média automaticamente
### 5. Frequência / Presença
- Buscar no array `data.attendance` onde `studentId` = aluno logado
- Mostrar calendário ou lista com os dias de presença/falta
- Exibir porcentagem total de frequência
- Justificativas de falta quando houver
### 6. Contratos
- Listar contratos do aluno (array `data.contracts` filtrado por `studentId`)
- Botão para visualizar o contrato completo (renderizar HTML)
- Botão para download/impressão
### 7. Certificados
- Listar certificados emitidos para o aluno
- Botão para visualizar/download
### 8. Meus Dados
- Exibir dados pessoais do aluno (somente leitura):
- Nome, CPF, RG, Data de Nascimento, Telefone, Email
- Endereço completo
- Dados do responsável (se tiver)
- Botão "Alterar Senha" que permite trocar a `portalPassword`
- Para salvar a nova senha: fazer PUT no server.js que atualiza o campo `portalPassword` do aluno no JSON `data.students` dentro da `school_data`
---
## Rotas do Backend (server.js)
```
POST /api/portal/login → Autenticação (retorna JWT)
GET /api/portal/me → Dados do aluno logado (protegido)
GET /api/portal/financeiro → Pagamentos do aluno (protegido)
GET /api/portal/notas → Notas/boletim do aluno (protegido)
GET /api/portal/frequencia → Registros de presença (protegido)
GET /api/portal/contratos → Contratos do aluno (protegido)
GET /api/portal/certificados → Certificados do aluno (protegido)
GET /api/portal/boletos → Boletos do Asaas (tabela alunos_cobrancas) (protegido)
PUT /api/portal/alterar-senha → Alterar senha do portal (protegido)
GET /api/portal/escola → Dados da escola + logo (público, para o login)
```
---
## Variáveis de Ambiente (Portainer)
```env
PORT=3001
VITE_SUPABASE_URL=https://xxxx.supabase.co
VITE_SUPABASE_KEY=eyJhb...
JWT_SECRET=uma-chave-secreta-forte-aqui
```
---
## Dockerfile (Multi-stage, igual ao EduManager)
```dockerfile
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production Stage ----
FROM node:22-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "server.js"]
```
---
## Regras e Restrições CRÍTICAS
1. **SOMENTE LEITURA** no Supabase — o portal NÃO deve criar, editar ou excluir alunos, pagamentos, notas, etc. A ÚNICA exceção é a rota `PUT /api/portal/alterar-senha` que atualiza o campo `portalPassword` dentro do JSON.
2. **Nunca expor dados de outros alunos** — todas as queries devem filtrar por `studentId` do JWT.
3. **Design Premium** — Use dark mode por padrão, gradientes modernos, animações suaves, tipografia Google Fonts (Inter ou Outfit). O visual deve ser profissional e bonito.
4. **Responsivo** — Deve funcionar perfeitamente em celulares (a maioria dos alunos acessará pelo celular).
5. **O projeto deve ser 100% funcional** — sem mocks, sem placeholder. Lê dados reais do Supabase.
6. **Separação total do EduManager** — Este é um projeto em pasta separada, repositório separado, container Docker separado. Eles compartilham apenas o banco Supabase.
---
## Estrutura de Pastas Esperada
```
portal-aluno/
├── server.js # Backend Express
├── Dockerfile
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── types.ts # Interfaces compartilhadas
│ ├── context/
│ │ └── AuthContext.tsx # Context de autenticação (JWT)
│ ├── pages/
│ │ ├── Login.tsx
│ │ ├── Dashboard.tsx
│ │ ├── Financeiro.tsx
│ │ ├── Notas.tsx
│ │ ├── Frequencia.tsx
│ │ ├── Contratos.tsx
│ │ ├── Certificados.tsx
│ │ └── MeusDados.tsx
│ ├── components/
│ │ ├── Sidebar.tsx
│ │ ├── Header.tsx
│ │ └── ProtectedRoute.tsx
│ └── styles/
│ └── index.css
```
---
**Gere o projeto completo com TODOS os arquivos acima, funcional e pronto para deploy no Docker/Portainer.**

View File

@ -0,0 +1,48 @@
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
const supabaseUrl = process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.VITE_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
async function analyzeDB() {
console.log('--- Analisando Banco de Dados Supabase ---');
// 1. Check school_data table
const { data: schoolData, error: schoolError } = await supabase
.from('school_data')
.select('*');
if (schoolError) {
console.error('❌ Erro ao ler tabela school_data:', schoolError.message);
} else {
console.log(`✅ Tabela school_data: ${schoolData.length} registros encontrados.`);
if (schoolData.length > 0) {
const firstRow = schoolData[0];
const data = firstRow.data || {};
console.log('\n--- Estrutura do JSON school_data (Campos Principais) ---');
console.log('Students:', data.students?.length || 0);
console.log('Lessons:', data.lessons?.length || 0);
console.log('Attendance:', data.attendance?.length || 0);
console.log('Payments:', data.payments?.length || 0);
console.log('Notifications:', data.notifications?.length || 0);
}
}
// 2. Check boletos table
const { data: boletos, error: boletosError } = await supabase
.from('boletos')
.select('count', { count: 'exact' });
if (boletosError) {
console.error('❌ Tabela boletos não encontrada ou erro:', boletosError.message);
} else {
console.log(`✅ Tabela boletos: ${boletos.length > 0 ? 'Exite' : 'Vazia'}`);
}
process.exit(0);
}
analyzeDB();

View File

@ -0,0 +1,55 @@
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();
const supabaseUrl = process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.VITE_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
async function dumpData() {
console.log('--- Lendo Dados Reais do Supabase ---');
const { data, error } = await supabase
.from('school_data')
.select('data')
.eq('id', 1)
.single();
if (error) {
console.error('❌ Erro:', error.message);
process.exit(1);
}
// Salvar em um arquivo temporário para análise
fs.writeFileSync('scratch/db_dump.json', JSON.stringify(data.data, null, 2));
console.log('✅ Dados salvos em scratch/db_dump.json para análise.');
// Resumo para o log
const d = data.data;
console.log('Students:', d.students?.length);
console.log('Lessons:', d.lessons?.length);
console.log('Attendance:', d.attendance?.length);
// Mostrar os últimos registros de attendance para ver o formato
console.log('\nÚltimos 3 registros de Attendance:');
console.log(JSON.stringify(d.attendance?.slice(-3), null, 2));
// Mostrar uma aula de hoje se houver
const todayStr = new Date().toISOString().split('T')[0];
const todayLesson = d.lessons?.find(l => l.date.includes(todayStr));
if (todayLesson) {
console.log('\nAula de hoje encontrada:', todayLesson);
} else {
console.log('\nNenhuma aula hoje no formato ISO:', todayStr);
// Tentar formato BR
const brDate = new Date().toLocaleDateString('pt-BR').replace(/\//g, '-');
const brLesson = d.lessons?.find(l => l.date.includes(brDate));
if (brLesson) console.log('Aula de hoje encontrada (BR):', brLesson);
}
process.exit(0);
}
dumpData();

View File

@ -0,0 +1,67 @@
import fs from 'fs';
import path from 'path';
const dir = 'c:\\Users\\Professor\\Desktop\\portalaluno\\src';
const replacements = [
{ regex: /#6366f1/ig, replace: "var(--color-primary)" },
{ regex: /#818cf8/ig, replace: "var(--color-primary-light)" },
{ regex: /#4f46e5/ig, replace: "var(--color-primary-dark)" },
{ regex: /#06b6d4/ig, replace: "var(--color-accent)" },
{ regex: /#22d3ee/ig, replace: "var(--color-accent-light)" },
{ regex: /#34d399/ig, replace: "var(--color-success)" },
{ regex: /#fbbf24/ig, replace: "var(--color-warning)" },
{ regex: /#f87171/ig, replace: "var(--color-danger)" },
{ regex: /'#1a1a1a'/ig, replace: "'var(--color-text)'" },
{ regex: /rgba\(99,\s*102,\s*241,\s*0\.1[25]\)/ig, replace: "var(--bg-primary-alpha)" },
{ regex: /rgba\(6,\s*182,\s*212,\s*0\.15?\)/ig, replace: "var(--bg-accent-alpha)" },
{ regex: /rgba\(239,\s*68,\s*68,\s*0\.1[25]\)/ig, replace: "var(--bg-danger-alpha)" },
{ regex: /rgba\(16,\s*185,\s*129,\s*0\.1[25]\)/ig, replace: "var(--bg-success-alpha)" },
{ regex: /rgba\(245,\s*158,\s*11,\s*0\.1[25]\)/ig, replace: "var(--bg-warning-alpha)" },
{ regex: /rgba\(245,\s*158,\s*11,\s*0\.2\)/ig, replace: "var(--bg-warning-alpha)" },
{ regex: /rgba\(234,\s*179,\s*8,\s*0\.1\)/ig, replace: "var(--bg-warning-alpha)" },
{ regex: /rgba\(0,\s*0,\s*0,\s*0\.[67]\)/ig, replace: "var(--overlay-bg)" },
{ regex: /rgba\(239,\s*68,\s*68,\s*0\.3\)/ig, replace: "var(--border-danger-alpha)" },
{ regex: /rgba\(16,\s*185,\s*129,\s*0\.3\)/ig, replace: "var(--border-success-alpha)" },
// gradients:
{ regex: /linear-gradient\(180deg, #0c1222 0%, #131b2e 50%, #0f172a 100%\)/g, replace: "var(--gradient-sidebar)" },
{ regex: /linear-gradient\(135deg, #0c1222 0%, #1a1040 50%, #0f172a 100%\)/g, replace: "var(--gradient-login)" },
{ regex: /linear-gradient\(135deg, rgba\(245,158,11,0\.2\) 0%, rgba\(234,179,8,0\.1\) 100%\)/g, replace: "var(--gradient-warning)" },
// header:
{ regex: /rgba\(15, 23, 42, 0\.9\)/g, replace: "var(--header-bg)" },
];
function processDir(dirPath) {
const files = fs.readdirSync(dirPath);
for (const file of files) {
const fullPath = path.join(dirPath, file);
if (fs.statSync(fullPath).isDirectory()) {
processDir(fullPath);
} else if (fullPath.endsWith('.tsx') || fullPath.endsWith('.ts')) {
let content = fs.readFileSync(fullPath, 'utf8');
let changed = false;
for (const { regex, replace } of replacements) {
if (regex.test(content)) {
content = content.replace(regex, replace);
changed = true;
}
}
// special cases for template strings
if (content.includes("`rgba(${avg >= 7 ? '16,185,129' : avg >= 5 ? '245,158,11' : '239,68,68'}, 0.15)`")) {
content = content.replace(
"`rgba(${avg >= 7 ? '16,185,129' : avg >= 5 ? '245,158,11' : '239,68,68'}, 0.15)`",
"avg >= 7 ? 'var(--bg-success-alpha)' : avg >= 5 ? 'var(--bg-warning-alpha)' : 'var(--bg-danger-alpha)'"
);
changed = true;
}
if (changed) {
fs.writeFileSync(fullPath, content);
console.log(`Updated ${file}`);
}
}
}
}
processDir(dir);

518
portal/server.js Normal file
View File

@ -0,0 +1,518 @@
/**
* ============================================================
* PORTAL DO ALUNO SERVER SELF-HOSTED
* ============================================================
* SUBSTITUIÇÃO CIRÚRGICA:
* - @supabase/supabase-js pg (PostgreSQL direto)
*
* TODAS AS ROTAS mantêm a mesma assinatura e resposta.
* O frontend React NÃO percebe a diferença.
* ============================================================
*/
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import jwt from 'jsonwebtoken';
import pg from 'pg';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!';
// === PostgreSQL (substitui Supabase) ===
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({
connectionString: DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
});
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// ===== Helper: Get school data (PostgreSQL) =====
async function getSchoolData() {
const { rows } = await pool.query(
'SELECT data FROM school_data WHERE id = 1'
);
return rows[0]?.data || {};
}
// ===== Helper: Save school data (PostgreSQL) =====
async function saveSchoolData(data) {
await pool.query(
`INSERT INTO school_data (id, data, updated_at)
VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = NOW()`,
[JSON.stringify(data)]
);
}
// ===== Auth Middleware =====
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Token inválido ou expirado' });
}
}
// ===================================================
// PUBLIC ROUTES
// ===================================================
// POST /api/portal/login
app.post('/api/portal/login', async (req, res) => {
try {
const { enrollmentNumber, password } = req.body;
if (!enrollmentNumber || !password) {
return res.status(400).json({ error: 'Matrícula e senha são obrigatórios' });
}
const schoolData = await getSchoolData();
const students = schoolData.students || [];
const student = students.find(
(s) => s.enrollmentNumber && s.enrollmentNumber.toLowerCase() === enrollmentNumber.toLowerCase()
);
if (!student) {
return res.status(401).json({ error: 'Matrícula não encontrada' });
}
// Check password — COPIADA EXATAMENTE como está no JSON
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
if (password !== expectedPassword) {
return res.status(401).json({ error: 'Senha incorreta' });
}
if (student.status !== 'active') {
return res.status(403).json({ error: 'Sua matrícula está inativa. Entre em contato com a secretaria.' });
}
const tokenPayload = {
studentId: student.id,
enrollmentNumber: student.enrollmentNumber,
name: student.name,
};
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '7d' });
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
const course = studentClass
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null;
res.json({
token,
user: tokenPayload,
student: { ...student, portalPassword: undefined },
class: studentClass,
course,
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// GET /api/portal/escola
app.get('/api/portal/escola', async (req, res) => {
try {
const schoolData = await getSchoolData();
res.json({
name: schoolData.profile?.name || 'Escola',
logo: schoolData.logo || null,
profile: schoolData.profile || null,
});
} catch (err) {
console.error('Escola error:', err);
res.status(500).json({ error: 'Erro ao buscar dados da escola' });
}
});
// ===================================================
// PROTECTED ROUTES
// ===================================================
// GET /api/portal/me
app.get('/api/portal/me', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find((s) => s.id === req.user.studentId);
if (!student) return res.status(404).json({ error: 'Aluno não encontrado' });
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
const course = studentClass
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null;
res.json({
student: { ...student, portalPassword: undefined },
class: studentClass,
course,
});
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/financeiro
app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const payments = (schoolData.payments || []).filter((p) => p.studentId === req.user.studentId);
res.json({ payments });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/boletos (PostgreSQL direto)
app.get('/api/portal/boletos', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC',
[req.user.studentId]
);
res.json({ boletos: rows || [] });
} catch (err) {
console.error('Boletos error:', err);
res.json({ boletos: [] });
}
});
// GET /api/portal/notas
app.get('/api/portal/notas', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const grades = (schoolData.grades || []).filter((g) => g.studentId === req.user.studentId);
const subjects = schoolData.subjects || [];
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId);
return { ...g, subjectName: subject?.name || 'Disciplina desconhecida' };
});
const periods = [...new Set(grades.map((g) => g.period))];
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
periods.sort();
res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/frequencia
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const attendance = (schoolData.attendance || []).filter((a) => a.studentId === req.user.studentId);
res.json({ attendance });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// POST /api/portal/frequencia/justificar
app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) => {
try {
const { date, justification } = req.body;
if (!date) return res.status(400).json({ error: 'A data da aula é obrigatória' });
if (!justification || justification.trim() === '') return res.status(400).json({ error: 'A justificativa é obrigatória' });
const schoolData = await getSchoolData();
const attendance = schoolData.attendance || [];
const notifications = schoolData.notifications || [];
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const fullDateStr = date;
let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr);
if (recordIndex !== -1) {
const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });
attendance[recordIndex] = { ...existing, justification: justification.trim() };
} else {
const newRecord = {
id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '',
date: fullDateStr, verified: false, type: 'absence', justification: justification.trim(),
};
attendance.push(newRecord);
recordIndex = attendance.length - 1;
}
let attachment = null;
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) {}
notifications.push({
id: `notif-${Date.now()}`, studentId: 'admin',
title: 'Nova Justificativa de Falta',
message: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
attachment, read: false, createdAt: new Date().toISOString(),
});
schoolData.attendance = attendance;
schoolData.notifications = notifications;
await saveSchoolData(schoolData);
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
} catch (err) {
console.error('Justificativa error:', err);
res.status(500).json({ error: 'Erro interno ao salvar justificativa' });
}
});
// GET /api/portal/contratos
app.get('/api/portal/contratos', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const contracts = (schoolData.contracts || []).filter((c) => c.studentId === req.user.studentId);
res.json({ contracts });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/certificados
app.get('/api/portal/certificados', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const certificates = (schoolData.certificates || []).filter((c) => c.studentId === req.user.studentId);
res.json({ certificates });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/config — Agora retorna dados do PostgreSQL, não mais Supabase
app.get('/api/portal/config', (req, res) => {
// O frontend usava isso para Supabase Realtime.
// No self-hosted, o frontend usará polling ou SSE.
res.json({
supabaseUrl: null,
supabaseAnonKey: null,
selfHosted: true,
});
});
// GET /api/portal/aulas
app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
if (!student) return res.json({ lessons: [] });
const parseDateHelper = (dStr) => {
if (!dStr) return 0;
const parts = dStr.substring(0, 10).split(/[-/]/);
if (parts.length < 3) return 0;
if (parts[0].length === 4) return new Date(parts[0], parts[1] - 1, parts[2]).getTime();
return new Date(parts[2], parts[1] - 1, parts[0]).getTime();
};
const lessons = (schoolData.lessons || [])
.filter(l => l.classId === student.classId)
.sort((a, b) => parseDateHelper(a.date) - parseDateHelper(b.date));
res.json({ lessons });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/notificacoes
app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const notifications = (schoolData.notifications || [])
.filter(n => n.studentId === req.user.studentId)
.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
res.json({ notifications });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// PUT /api/portal/notificacoes/ler/:id
app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const schoolData = await getSchoolData();
const notifications = schoolData.notifications || [];
const idx = notifications.findIndex(n => n.id === id && n.studentId === req.user.studentId);
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
notifications[idx] = { ...notifications[idx], read: true };
schoolData.notifications = notifications;
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// DELETE /api/portal/notificacoes/:id
app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const schoolData = await getSchoolData();
schoolData.notifications = (schoolData.notifications || []).filter(
n => !(n.id === id && n.studentId === req.user.studentId)
);
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// PUT /api/portal/alterar-senha
app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Campos obrigatórios' });
if (newPassword.length < 4) return res.status(400).json({ error: 'Mínimo 4 caracteres' });
const schoolData = await getSchoolData();
const students = schoolData.students || [];
const studentIndex = students.findIndex((s) => s.id === req.user.studentId);
if (studentIndex === -1) return res.status(404).json({ error: 'Aluno não encontrado' });
const student = students[studentIndex];
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
if (currentPassword !== expectedPassword) return res.status(401).json({ error: 'Senha atual incorreta' });
students[studentIndex] = { ...student, portalPassword: newPassword };
schoolData.students = students;
await saveSchoolData(schoolData);
res.json({ message: 'Senha alterada com sucesso' });
} catch (err) {
res.status(500).json({ error: 'Erro ao alterar senha' });
}
});
// ===================================================
// AVALIAÇÕES (Exams) — PostgreSQL direto para submissões
// ===================================================
app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
if (!student) return res.json({ exams: [], submissions: [] });
const exams = (schoolData.exams || [])
.filter(e => e.status === 'published' && e.classId === student.classId)
.map(e => ({
...e,
questions: e.questions.map(q => ({ id: q.id, text: q.text, options: q.options }))
}));
const { rows: submissions } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1',
[req.user.studentId]
);
// Mapear nomes de colunas do banco para o formato esperado pelo frontend
const mappedSubmissions = (submissions || []).map(s => ({
...s,
exam_id: s.prova_id || s.exam_id,
total_questions: s.total_questoes || s.total_questions,
correct_count: s.acertos || s.correct_count,
wrong_count: s.erros || s.wrong_count,
percentage: s.percentual || s.percentage,
final_score: s.nota_final || s.final_score,
answers_json: s.respostas || s.answers_json,
}));
res.json({ exams, submissions: mappedSubmissions });
} catch (err) {
console.error('Avaliacoes error:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) => {
try {
const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
// Verificar se já submeteu
const { rows: existing } = await pool.query(
'SELECT id FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
if (existing.length > 0) return res.status(409).json({ error: 'Você já realizou esta prova.' });
const schoolData = await getSchoolData();
const exam = (schoolData.exams || []).find(e => e.id === examId);
if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
const totalQuestions = exam.questions.length;
let correctCount = 0;
for (const q of exam.questions) {
if (answers[q.id] !== undefined && answers[q.id] === q.correctOptionIndex) correctCount++;
}
const wrongCount = totalQuestions - correctCount;
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 10).toFixed(2)) : 0;
// Salvar no PostgreSQL
await pool.query(
`INSERT INTO provas_submissoes (aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[req.user.studentId, examId, totalQuestions, correctCount, wrongCount, percentage, finalScore, JSON.stringify(answers), new Date().toISOString()]
);
// Integrar com grades no school_data
if (exam.subjectId && exam.periodId) {
const grades = schoolData.grades || [];
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId);
if (existingGradeIndex >= 0) {
grades[existingGradeIndex].value = finalScore;
} else {
grades.push({ id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`, studentId: req.user.studentId, subjectId: exam.subjectId, period: exam.periodId, value: finalScore });
}
schoolData.grades = grades;
await saveSchoolData(schoolData);
}
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });
} catch (err) {
console.error('Submissao error:', err);
res.status(500).json({ error: 'Erro interno.' });
}
});
// ===================================================
// SERVE FRONTEND
// ===================================================
const distPath = path.join(__dirname, 'dist');
app.use(express.static(distPath));
app.use((req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
// ===================================================
// START SERVER
// ===================================================
app.listen(PORT, () => {
console.log(`🚀 Portal do Aluno Self-Hosted na porta ${PORT}`);
console.log(`📡 PostgreSQL: ${DATABASE_URL.split('@')[1] || 'local'}`);
});

518
portal/server.selfhosted.js Normal file
View File

@ -0,0 +1,518 @@
/**
* ============================================================
* PORTAL DO ALUNO SERVER SELF-HOSTED
* ============================================================
* SUBSTITUIÇÃO CIRÚRGICA:
* - @supabase/supabase-js pg (PostgreSQL direto)
*
* TODAS AS ROTAS mantêm a mesma assinatura e resposta.
* O frontend React NÃO percebe a diferença.
* ============================================================
*/
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import jwt from 'jsonwebtoken';
import pg from 'pg';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'EduManager-JWT-Secret-2026!';
// === PostgreSQL (substitui Supabase) ===
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({
connectionString: DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
});
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// ===== Helper: Get school data (PostgreSQL) =====
async function getSchoolData() {
const { rows } = await pool.query(
'SELECT data FROM school_data WHERE id = 1'
);
return rows[0]?.data || {};
}
// ===== Helper: Save school data (PostgreSQL) =====
async function saveSchoolData(data) {
await pool.query(
`INSERT INTO school_data (id, data, updated_at)
VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = NOW()`,
[JSON.stringify(data)]
);
}
// ===== Auth Middleware =====
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Token inválido ou expirado' });
}
}
// ===================================================
// PUBLIC ROUTES
// ===================================================
// POST /api/portal/login
app.post('/api/portal/login', async (req, res) => {
try {
const { enrollmentNumber, password } = req.body;
if (!enrollmentNumber || !password) {
return res.status(400).json({ error: 'Matrícula e senha são obrigatórios' });
}
const schoolData = await getSchoolData();
const students = schoolData.students || [];
const student = students.find(
(s) => s.enrollmentNumber && s.enrollmentNumber.toLowerCase() === enrollmentNumber.toLowerCase()
);
if (!student) {
return res.status(401).json({ error: 'Matrícula não encontrada' });
}
// Check password — COPIADA EXATAMENTE como está no JSON
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
if (password !== expectedPassword) {
return res.status(401).json({ error: 'Senha incorreta' });
}
if (student.status !== 'active') {
return res.status(403).json({ error: 'Sua matrícula está inativa. Entre em contato com a secretaria.' });
}
const tokenPayload = {
studentId: student.id,
enrollmentNumber: student.enrollmentNumber,
name: student.name,
};
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '7d' });
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
const course = studentClass
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null;
res.json({
token,
user: tokenPayload,
student: { ...student, portalPassword: undefined },
class: studentClass,
course,
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// GET /api/portal/escola
app.get('/api/portal/escola', async (req, res) => {
try {
const schoolData = await getSchoolData();
res.json({
name: schoolData.profile?.name || 'Escola',
logo: schoolData.logo || null,
profile: schoolData.profile || null,
});
} catch (err) {
console.error('Escola error:', err);
res.status(500).json({ error: 'Erro ao buscar dados da escola' });
}
});
// ===================================================
// PROTECTED ROUTES
// ===================================================
// GET /api/portal/me
app.get('/api/portal/me', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find((s) => s.id === req.user.studentId);
if (!student) return res.status(404).json({ error: 'Aluno não encontrado' });
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
const course = studentClass
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
: null;
res.json({
student: { ...student, portalPassword: undefined },
class: studentClass,
course,
});
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/financeiro
app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const payments = (schoolData.payments || []).filter((p) => p.studentId === req.user.studentId);
res.json({ payments });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/boletos (PostgreSQL direto)
app.get('/api/portal/boletos', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC',
[req.user.studentId]
);
res.json({ boletos: rows || [] });
} catch (err) {
console.error('Boletos error:', err);
res.json({ boletos: [] });
}
});
// GET /api/portal/notas
app.get('/api/portal/notas', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const grades = (schoolData.grades || []).filter((g) => g.studentId === req.user.studentId);
const subjects = schoolData.subjects || [];
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId);
return { ...g, subjectName: subject?.name || 'Disciplina desconhecida' };
});
const periods = [...new Set(grades.map((g) => g.period))];
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
periods.sort();
res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/frequencia
app.get('/api/portal/frequencia', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const attendance = (schoolData.attendance || []).filter((a) => a.studentId === req.user.studentId);
res.json({ attendance });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// POST /api/portal/frequencia/justificar
app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) => {
try {
const { date, justification } = req.body;
if (!date) return res.status(400).json({ error: 'A data da aula é obrigatória' });
if (!justification || justification.trim() === '') return res.status(400).json({ error: 'A justificativa é obrigatória' });
const schoolData = await getSchoolData();
const attendance = schoolData.attendance || [];
const notifications = schoolData.notifications || [];
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
const fullDateStr = date;
let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr);
if (recordIndex !== -1) {
const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });
attendance[recordIndex] = { ...existing, justification: justification.trim() };
} else {
const newRecord = {
id: `att-just-${Date.now()}`, studentId: req.user.studentId, classId: student?.classId || '',
date: fullDateStr, verified: false, type: 'absence', justification: justification.trim(),
};
attendance.push(newRecord);
recordIndex = attendance.length - 1;
}
let attachment = null;
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) {}
notifications.push({
id: `notif-${Date.now()}`, studentId: 'admin',
title: 'Nova Justificativa de Falta',
message: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
attachment, read: false, createdAt: new Date().toISOString(),
});
schoolData.attendance = attendance;
schoolData.notifications = notifications;
await saveSchoolData(schoolData);
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
} catch (err) {
console.error('Justificativa error:', err);
res.status(500).json({ error: 'Erro interno ao salvar justificativa' });
}
});
// GET /api/portal/contratos
app.get('/api/portal/contratos', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const contracts = (schoolData.contracts || []).filter((c) => c.studentId === req.user.studentId);
res.json({ contracts });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/certificados
app.get('/api/portal/certificados', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const certificates = (schoolData.certificates || []).filter((c) => c.studentId === req.user.studentId);
res.json({ certificates });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/config — Agora retorna dados do PostgreSQL, não mais Supabase
app.get('/api/portal/config', (req, res) => {
// O frontend usava isso para Supabase Realtime.
// No self-hosted, o frontend usará polling ou SSE.
res.json({
supabaseUrl: null,
supabaseAnonKey: null,
selfHosted: true,
});
});
// GET /api/portal/aulas
app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
if (!student) return res.json({ lessons: [] });
const parseDateHelper = (dStr) => {
if (!dStr) return 0;
const parts = dStr.substring(0, 10).split(/[-/]/);
if (parts.length < 3) return 0;
if (parts[0].length === 4) return new Date(parts[0], parts[1] - 1, parts[2]).getTime();
return new Date(parts[2], parts[1] - 1, parts[0]).getTime();
};
const lessons = (schoolData.lessons || [])
.filter(l => l.classId === student.classId)
.sort((a, b) => parseDateHelper(a.date) - parseDateHelper(b.date));
res.json({ lessons });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// GET /api/portal/notificacoes
app.get('/api/portal/notificacoes', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const notifications = (schoolData.notifications || [])
.filter(n => n.studentId === req.user.studentId)
.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
res.json({ notifications });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// PUT /api/portal/notificacoes/ler/:id
app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const schoolData = await getSchoolData();
const notifications = schoolData.notifications || [];
const idx = notifications.findIndex(n => n.id === id && n.studentId === req.user.studentId);
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
notifications[idx] = { ...notifications[idx], read: true };
schoolData.notifications = notifications;
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// DELETE /api/portal/notificacoes/:id
app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
try {
const { id } = req.params;
const schoolData = await getSchoolData();
schoolData.notifications = (schoolData.notifications || []).filter(
n => !(n.id === id && n.studentId === req.user.studentId)
);
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Erro interno' });
}
});
// PUT /api/portal/alterar-senha
app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Campos obrigatórios' });
if (newPassword.length < 4) return res.status(400).json({ error: 'Mínimo 4 caracteres' });
const schoolData = await getSchoolData();
const students = schoolData.students || [];
const studentIndex = students.findIndex((s) => s.id === req.user.studentId);
if (studentIndex === -1) return res.status(404).json({ error: 'Aluno não encontrado' });
const student = students[studentIndex];
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
if (currentPassword !== expectedPassword) return res.status(401).json({ error: 'Senha atual incorreta' });
students[studentIndex] = { ...student, portalPassword: newPassword };
schoolData.students = students;
await saveSchoolData(schoolData);
res.json({ message: 'Senha alterada com sucesso' });
} catch (err) {
res.status(500).json({ error: 'Erro ao alterar senha' });
}
});
// ===================================================
// AVALIAÇÕES (Exams) — PostgreSQL direto para submissões
// ===================================================
app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
try {
const schoolData = await getSchoolData();
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
if (!student) return res.json({ exams: [], submissions: [] });
const exams = (schoolData.exams || [])
.filter(e => e.status === 'published' && e.classId === student.classId)
.map(e => ({
...e,
questions: e.questions.map(q => ({ id: q.id, text: q.text, options: q.options }))
}));
const { rows: submissions } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1',
[req.user.studentId]
);
// Mapear nomes de colunas do banco para o formato esperado pelo frontend
const mappedSubmissions = (submissions || []).map(s => ({
...s,
exam_id: s.prova_id || s.exam_id,
total_questions: s.total_questoes || s.total_questions,
correct_count: s.acertos || s.correct_count,
wrong_count: s.erros || s.wrong_count,
percentage: s.percentual || s.percentage,
final_score: s.nota_final || s.final_score,
answers_json: s.respostas || s.answers_json,
}));
res.json({ exams, submissions: mappedSubmissions });
} catch (err) {
console.error('Avaliacoes error:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) => {
try {
const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
// Verificar se já submeteu
const { rows: existing } = await pool.query(
'SELECT id FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
if (existing.length > 0) return res.status(409).json({ error: 'Você já realizou esta prova.' });
const schoolData = await getSchoolData();
const exam = (schoolData.exams || []).find(e => e.id === examId);
if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
const totalQuestions = exam.questions.length;
let correctCount = 0;
for (const q of exam.questions) {
if (answers[q.id] !== undefined && answers[q.id] === q.correctOptionIndex) correctCount++;
}
const wrongCount = totalQuestions - correctCount;
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 10).toFixed(2)) : 0;
// Salvar no PostgreSQL
await pool.query(
`INSERT INTO provas_submissoes (aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[req.user.studentId, examId, totalQuestions, correctCount, wrongCount, percentage, finalScore, JSON.stringify(answers), new Date().toISOString()]
);
// Integrar com grades no school_data
if (exam.subjectId && exam.periodId) {
const grades = schoolData.grades || [];
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId);
if (existingGradeIndex >= 0) {
grades[existingGradeIndex].value = finalScore;
} else {
grades.push({ id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`, studentId: req.user.studentId, subjectId: exam.subjectId, period: exam.periodId, value: finalScore });
}
schoolData.grades = grades;
await saveSchoolData(schoolData);
}
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });
} catch (err) {
console.error('Submissao error:', err);
res.status(500).json({ error: 'Erro interno.' });
}
});
// ===================================================
// SERVE FRONTEND
// ===================================================
const distPath = path.join(__dirname, 'dist');
app.use(express.static(distPath));
app.use((req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
// ===================================================
// START SERVER
// ===================================================
app.listen(PORT, () => {
console.log(`🚀 Portal do Aluno Self-Hosted na porta ${PORT}`);
console.log(`📡 PostgreSQL: ${DATABASE_URL.split('@')[1] || 'local'}`);
});

139
portal/src/App.tsx Normal file
View File

@ -0,0 +1,139 @@
import { Routes, Route, Navigate, Link } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import MinhasAulas from './pages/MinhasAulas';
import Financeiro from './pages/Financeiro';
import Notas from './pages/Notas';
import Frequencia from './pages/Frequencia';
import Contratos from './pages/Contratos';
import Certificados from './pages/Certificados';
import MeusDados from './pages/MeusDados';
import Avaliacoes from './pages/Avaliacoes';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import { useState, useEffect } from 'react';
import { AlertCircle } from 'lucide-react';
function AppLayout() {
const { token } = useAuth();
const [overdueCount, setOverdueCount] = useState(0);
const normalizeStatus = (payment: any) => {
const s = payment.status?.toLowerCase();
if (['paid', 'received', 'confirmed', 'pago'].includes(s)) return 'paid';
if (['cancelled', 'cancelado'].includes(s)) return 'cancelled';
// Check if explicitly overdue in database
if (['overdue', 'atrasado', 'atrasada', 'vencido'].includes(s)) return 'overdue';
// Everything else that is not paid/cancelled is treated as pending
return 'pending';
};
useEffect(() => {
if (!token) return;
const checkFinance = async () => {
try {
const res = await fetch('/api/portal/financeiro', {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
const atrasadas = (data.payments || []).filter((p: any) =>
normalizeStatus(p) === 'overdue'
).length;
setOverdueCount(atrasadas);
}
} catch (err) {
console.error('Erro ao verificar financeiro:', err);
}
};
checkFinance();
const interval = setInterval(checkFinance, 60000); // Check every minute
return () => clearInterval(interval);
}, [token]);
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--color-surface)' }}>
<Sidebar />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Header />
{overdueCount > 0 && (
<div style={{
background: 'var(--color-danger)',
color: 'white',
padding: '10px 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
fontSize: '0.875rem',
fontWeight: 600,
zIndex: 90,
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.2)',
animation: 'fadeIn 0.3s ease-out'
}}>
<AlertCircle size={18} />
<span>Atenção: Você possui {overdueCount} {overdueCount === 1 ? 'parcela atrasada' : 'parcelas atrasadas'}. Por favor, regularize seu financeiro.</span>
<Link to="/financeiro?filter=overdue" style={{
color: 'white',
textDecoration: 'underline',
marginLeft: '10px',
fontSize: '0.8rem',
opacity: 0.9,
fontWeight: 700
}}>Ver parcelas atrasadas</Link>
</div>
)}
<main style={{ flex: 1, overflow: 'auto' }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/minhas-aulas" element={<MinhasAulas />} />
<Route path="/financeiro" element={<Financeiro />} />
<Route path="/notas" element={<Notas />} />
<Route path="/frequencia" element={<Frequencia />} />
<Route path="/contratos" element={<Contratos />} />
<Route path="/certificados" element={<Certificados />} />
<Route path="/meus-dados" element={<MeusDados />} />
<Route path="/avaliacoes" element={<Avaliacoes />} />
</Routes>
</main>
</div>
</div>
);
}
export default function App() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: '100vh', background: 'var(--color-surface)'
}}>
<div className="skeleton" style={{ width: 48, height: 48, borderRadius: '50%' }} />
</div>
);
}
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route
path="/*"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
</Routes>
);
}

View File

@ -0,0 +1,111 @@
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { Moon, Sun } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import Notifications from './Notifications';
const pageTitles: Record<string, string> = {
'/': 'Dashboard',
'/minhas-aulas': 'Cronograma',
'/financeiro': 'Financeiro',
'/notas': 'Notas & Boletim',
'/frequencia': 'Frequência',
'/contratos': 'Contratos',
'/certificados': 'Certificados',
'/meus-dados': 'Meus Dados',
};
export default function Header() {
const { student } = useAuth();
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const title = pageTitles[location.pathname] || 'Portal do Aluno';
return (
<header style={{
height: 64,
borderBottom: '1px solid var(--glass-border)',
background: 'var(--header-bg)',
backdropFilter: 'blur(12px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 1.5rem 0 1.5rem',
position: 'sticky',
top: 0,
zIndex: 100,
}}
className="portal-header"
>
<div style={{ marginLeft: 0 }} className="header-title-area">
<h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>{title}</h2>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem',
}}>
<button
onClick={toggleTheme}
style={{
width: 38, height: 38, borderRadius: 10,
background: 'var(--color-surface-light)', border: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)',
transition: 'all 0.2s ease', position: 'relative',
}}
title={theme === 'dark' ? 'Mudar para modo claro' : 'Mudar para modo escuro'}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-primary)';
e.currentTarget.style.color = 'var(--color-primary-light)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Notifications />
{student && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.625rem',
}}
className="header-student-info"
>
<div style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--gradient-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.8125rem', fontWeight: 700,
overflow: 'hidden', flexShrink: 0,
}}>
{student.photo ? (
<img src={student.photo} alt={student.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
student.name.charAt(0).toUpperCase()
)}
</div>
<div className="header-student-name">
<p style={{ fontSize: '0.8125rem', fontWeight: 600, lineHeight: 1.2 }}>
{student.name.split(' ')[0]}
</p>
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-secondary)' }}>
Aluno
</p>
</div>
</div>
)}
</div>
<style>{`
@media (max-width: 768px) {
.header-title-area { margin-left: 52px !important; }
.header-student-name { display: none !important; }
}
`}</style>
</header>
);
}

View File

@ -0,0 +1,281 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Bell, AlertCircle, Info, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import type { Notification as PortalNotification } from '../types';
export default function Notifications() {
const { token } = useAuth();
const [notifications, setNotifications] = useState<PortalNotification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const fetchNotifications = useCallback(async () => {
if (!token) return;
try {
// Fetch core notifications
const res = await fetch('/api/portal/notificacoes', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
let allNotifs = data.notifications || [];
// Fetch financial status for dynamic alert
const finRes = await fetch('/api/portal/financeiro', {
headers: { Authorization: `Bearer ${token}` }
});
if (finRes.ok) {
const finData = await finRes.json();
const atrasadas = (finData.payments || []).filter((p: any) => p.status === 'atrasada');
if (atrasadas.length > 0) {
const overdueNotif: PortalNotification = {
id: 'finance-overdue',
title: 'Pagamento Pendente',
message: `Identificamos ${atrasadas.length} ${atrasadas.length === 1 ? 'parcela atrasada' : 'parcelas atrasadas'}. Regularize agora para evitar suspensões.`,
read: false,
createdAt: new Date().toISOString(),
type: 'alert'
};
allNotifs = [overdueNotif, ...allNotifs];
}
}
setNotifications(allNotifs);
} catch (err) {
console.error('Erro ao buscar notificações', err);
}
}, [token]);
useEffect(() => {
fetchNotifications();
// Polling a cada 30s para simular realtime via SchoolData
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, [fetchNotifications]);
const navigate = useNavigate();
const unreadCount = notifications.filter(n => !n.read).length;
const markAsRead = async (id: string) => {
try {
await fetch(`/api/portal/notificacoes/ler/${id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
});
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
} catch (err) {
console.error(err);
}
};
const deleteAllRead = async () => {
const readNotifs = notifications.filter(n => n.read);
// Remove locally first for instant UI feedback
setNotifications(prev => prev.filter(n => !n.read));
// Then fire API calls
for (const notif of readNotifs) {
try {
await fetch(`/api/portal/notificacoes/${notif.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.error(err);
}
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return 'Agora';
try {
return new Date(dateStr).toLocaleString('pt-BR');
} catch {
return dateStr;
}
};
return (
<div ref={dropdownRef} style={{ position: 'relative' }}>
<style>{`
@keyframes pulse-bell {
0% { transform: scale(1); }
15% { transform: scale(1.15) rotate(8deg); }
30% { transform: scale(1.15) rotate(-8deg); }
45% { transform: scale(1.15) rotate(5deg); }
60% { transform: scale(1.15) rotate(-5deg); }
75% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes badge-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.25); opacity: 0.8; }
}
`}</style>
<button
onClick={() => setIsOpen(!isOpen)}
style={{
width: 38, height: 38, borderRadius: 10,
background: 'var(--color-surface-light)', border: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)',
transition: 'all 0.2s ease', position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-primary)';
e.currentTarget.style.color = 'var(--color-primary-light)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
>
<Bell size={18} style={{
animation: unreadCount > 0 ? 'pulse-bell 2s ease-in-out infinite' : undefined,
}} />
{unreadCount > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: 'var(--color-danger)', color: 'white',
width: 18, height: 18, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.65rem', fontWeight: 'bold',
animation: 'badge-pulse 1.5s ease-in-out infinite',
}}>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 10px)', right: 0,
width: 340, background: 'var(--color-surface)',
border: '1px solid var(--glass-border)', borderRadius: 12,
boxShadow: '0 10px 25px rgba(0,0,0,0.15)', zIndex: 1000,
overflow: 'hidden',
}}
className="animate-scale-in"
>
<div style={{
padding: '1rem', borderBottom: '1px solid var(--glass-border)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<h3 style={{ fontSize: '0.9rem', fontWeight: 600 }}>Notificações</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{unreadCount} {unreadCount === 1 ? 'não lida' : 'não lidas'}
</span>
{notifications.some(n => n.read) && (
<button
onClick={deleteAllRead}
title="Excluir todas as lidas"
style={{
background: 'none', border: 'none', color: 'var(--color-danger)',
cursor: 'pointer', padding: 4, borderRadius: 4,
display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.7rem',
opacity: 0.7, transition: 'opacity 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '0.7'; }}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div style={{ maxHeight: 380, overflowY: 'auto' }}>
{notifications.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
<Bell size={32} style={{ opacity: 0.2, margin: '0 auto 10px' }} />
<p style={{ fontSize: '0.85rem' }}>Nenhuma notificação</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{notifications.map(notif => {
const msgLower = (notif.title + ' ' + notif.message).toLowerCase();
const isCancelamento = msgLower.includes('cancel') || msgLower.includes('exclu') || msgLower.includes('remov');
const isReposicao = msgLower.includes('reposi');
const isExtra = msgLower.includes('extra');
const isReagendamento = msgLower.includes('reagend') || msgLower.includes('altera');
return (
<div
key={notif.id}
onClick={() => {
if (!notif.read) markAsRead(notif.id);
if (notif.id === 'finance-overdue') {
navigate('/financeiro?filter=overdue');
setIsOpen(false);
} else if (isCancelamento || isReagendamento || isExtra || isReposicao) {
navigate('/minhas-aulas');
setIsOpen(false);
}
}}
style={{
padding: '1rem', borderBottom: '1px solid var(--glass-border)',
background: notif.read ? 'transparent' : 'var(--bg-primary-alpha)',
display: 'flex', gap: '0.75rem', alignItems: 'flex-start',
transition: 'all 0.3s ease',
opacity: notif.read ? 0.5 : 1,
cursor: 'pointer',
}}
>
<div style={{
padding: 8, borderRadius: '50%', flexShrink: 0,
background: notif.read ? 'var(--color-surface-light)'
: isCancelamento ? 'var(--bg-danger-alpha)'
: isReagendamento ? 'var(--bg-warning-alpha)'
: isReposicao ? 'var(--bg-success-alpha)'
: isExtra ? 'rgba(147, 51, 234, 0.1)'
: 'var(--bg-primary-alpha)',
}}>
{isCancelamento || isReagendamento
? <AlertCircle size={16} color={notif.read ? 'var(--color-text-secondary)' : (isCancelamento ? 'var(--color-danger)' : 'var(--color-warning)')} />
: <Info size={16} color={notif.read ? 'var(--color-text-secondary)' : (isExtra ? '#a855f7' : isReposicao ? 'var(--color-success)' : 'var(--color-primary)')} />
}
</div>
<div style={{ flex: 1 }}>
<p style={{
fontSize: '0.8125rem', fontWeight: 600,
color: notif.read ? 'var(--color-text-secondary)' : (isCancelamento ? 'var(--color-danger)' : 'var(--color-text)'),
marginBottom: 2,
}}>
{notif.title}
</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginBottom: 4 }}>
{notif.message}
</p>
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)' }}>
{formatDate(notif.createdAt)}
</span>
</div>
{!notif.read && (
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: 'var(--color-primary)', flexShrink: 0,
marginTop: 6,
}} />
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,10 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) return null;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}

View File

@ -0,0 +1,263 @@
import { NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
LayoutDashboard, CreditCard, BookOpen, CalendarCheck, CalendarClock,
FileText, Award, User, LogOut, GraduationCap, X, Menu, ClipboardList
} from 'lucide-react';
import { useState, useEffect } from 'react';
const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/minhas-aulas', label: 'Cronograma', icon: CalendarClock },
{ path: '/financeiro', label: 'Financeiro', icon: CreditCard },
{ path: '/notas', label: 'Notas', icon: BookOpen },
{ path: '/avaliacoes', label: 'Avaliações', icon: ClipboardList },
{ path: '/frequencia', label: 'Frequência', icon: CalendarCheck },
{ path: '/contratos', label: 'Contratos', icon: FileText },
{ path: '/certificados', label: 'Certificados', icon: Award },
{ path: '/meus-dados', label: 'Meus Dados', icon: User },
];
export default function Sidebar() {
const { logout, student, schoolLogo } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
const location = useLocation();
useEffect(() => {
setMobileOpen(false);
}, [location]);
return (
<>
{/* Mobile toggle */}
<button
id="sidebar-toggle"
onClick={() => setMobileOpen(true)}
style={{
position: 'fixed', top: 16, left: 16, zIndex: 1001,
background: 'var(--glass-bg)', backdropFilter: 'blur(12px)',
border: '1px solid var(--glass-border)', borderRadius: 12,
padding: 10, color: 'var(--color-text)', cursor: 'pointer',
display: 'none',
}}
className="mobile-sidebar-toggle"
>
<Menu size={22} />
</button>
{/* Overlay */}
{mobileOpen && (
<div
onClick={() => setMobileOpen(false)}
style={{
position: 'fixed', inset: 0, background: 'var(--overlay-bg)',
zIndex: 1099, display: 'block',
}}
/>
)}
{/* Sidebar */}
<aside
style={{
width: desktopCollapsed ? 80 : 280,
minHeight: '100vh',
background: 'var(--gradient-sidebar)',
borderRight: '1px solid var(--glass-border)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 1100,
transition: 'width 0.3s ease, transform 0.3s ease',
}}
className={`sidebar ${mobileOpen ? 'sidebar-open' : ''}`}
>
{/* Desktop Collapse Toggle */}
<button
onClick={() => setDesktopCollapsed(!desktopCollapsed)}
className="desktop-collapse-btn"
title={desktopCollapsed ? "Expandir menu" : "Recolher menu"}
style={{
position: 'absolute', top: 22, right: -14, zIndex: 1200,
background: 'var(--color-surface)', border: '1px solid var(--glass-border)',
borderRadius: '50%', width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
{desktopCollapsed ? <Menu size={14} /> : <X size={14} />}
</button>
{/* Mobile close */}
<button
onClick={() => setMobileOpen(false)}
className="mobile-close-btn"
style={{
position: 'absolute', top: 12, right: 12, display: 'none',
background: 'none', border: 'none', color: 'var(--color-text-secondary)',
cursor: 'pointer', padding: 4,
}}
>
<X size={20} />
</button>
{/* Logo */}
<div style={{
padding: desktopCollapsed ? '2rem 1rem' : '2rem 1.5rem',
borderBottom: '1px solid var(--glass-border)',
display: 'flex', alignItems: 'center', justifyContent: desktopCollapsed ? 'center' : 'flex-start',
gap: '0.75rem', transition: 'all 0.3s ease',
}}>
{schoolLogo ? (
<img
src={schoolLogo}
alt="EduManager"
style={{
width: desktopCollapsed ? 40 : 44, height: desktopCollapsed ? 40 : 44,
objectFit: 'contain', transition: 'all 0.3s ease', borderRadius: 8,
}}
/>
) : (
<div style={{
width: desktopCollapsed ? 40 : 44, height: desktopCollapsed ? 40 : 44,
borderRadius: 14, background: 'var(--gradient-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, transition: 'all 0.3s ease',
}}>
<GraduationCap size={desktopCollapsed ? 20 : 24} color="white" />
</div>
)}
{!desktopCollapsed && (
<div className="sidebar-text" style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
Portal do <span className="gradient-text">Aluno</span>
</h1>
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', marginTop: 2 }}>
EduManager
</p>
</div>
)}
</div>
{/* Student Info */}
{student && (
<div style={{
padding: desktopCollapsed ? '1rem' : '1.25rem 1.5rem',
borderBottom: '1px solid var(--glass-border)',
display: 'flex', alignItems: 'center', justifyContent: desktopCollapsed ? 'center' : 'flex-start',
gap: '0.75rem', transition: 'all 0.3s ease',
}}>
<div style={{
width: desktopCollapsed ? 36 : 40, height: desktopCollapsed ? 36 : 40,
borderRadius: '50%', background: 'var(--gradient-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.9rem', fontWeight: 700, flexShrink: 0,
overflow: 'hidden', transition: 'all 0.3s ease', color: 'white'
}}>
{student.photo ? (
<img src={student.photo} alt={student.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
student.name.charAt(0).toUpperCase()
)}
</div>
{!desktopCollapsed && (
<div className="sidebar-text" style={{ overflow: 'hidden', flex: 1, whiteSpace: 'nowrap' }}>
<p style={{
fontSize: '0.8125rem', fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{student.name}
</p>
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-secondary)' }}>
{student.enrollmentNumber}
</p>
</div>
)}
</div>
)}
{/* Navigation */}
<nav style={{
flex: 1, padding: '1rem 0.75rem',
display: 'flex', flexDirection: 'column', gap: '0.25rem',
overflowY: 'auto', overflowX: 'hidden'
}}>
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
title={desktopCollapsed ? item.label : undefined}
style={({ isActive }) => ({
display: 'flex', alignItems: 'center',
justifyContent: desktopCollapsed ? 'center' : 'flex-start',
gap: '0.75rem',
padding: desktopCollapsed ? '0.75rem 0' : '0.75rem 1rem',
borderRadius: 12,
textDecoration: 'none', fontSize: '0.875rem', fontWeight: 500,
transition: 'all 0.2s ease',
color: isActive ? 'white' : 'var(--color-text-secondary)',
background: isActive ? 'var(--color-success)' : 'transparent',
boxShadow: isActive ? '0 4px 12px var(--bg-success-alpha)' : 'none',
borderLeft: isActive ? '4px solid var(--color-success)' : '4px solid transparent',
})}
>
<item.icon size={20} />
{!desktopCollapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
{/* Logout */}
<div style={{ padding: '1rem 0.75rem', borderTop: '1px solid var(--glass-border)' }}>
<button
id="logout-btn"
onClick={logout}
title={desktopCollapsed ? "Sair" : undefined}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: desktopCollapsed ? 'center' : 'flex-start',
gap: '0.75rem',
padding: desktopCollapsed ? '0.75rem 0' : '0.75rem 1rem',
borderRadius: 12, border: 'none',
background: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)',
cursor: 'pointer', fontSize: '0.875rem', fontWeight: 500,
transition: 'all 0.2s ease', fontFamily: 'Inter, sans-serif',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
>
<LogOut size={20} />
{!desktopCollapsed && <span>Sair</span>}
</button>
</div>
</aside>
<style>{`
@media (max-width: 768px) {
.mobile-sidebar-toggle { display: flex !important; }
.mobile-close-btn { display: block !important; }
.desktop-collapse-btn { display: none !important; }
.sidebar {
position: fixed !important;
left: 0; top: 0;
width: 280px !important;
transform: translateX(-100%);
box-shadow: 4px 0 24px rgba(0,0,0,0.4);
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-text { display: block !important; }
}
`}</style>
</>
);
}

View File

@ -0,0 +1,129 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AuthUser, Student } from '../types';
interface AuthContextType {
user: AuthUser | null;
student: Student | null;
token: string | null;
isLoading: boolean;
schoolLogo: string | null;
login: (enrollmentNumber: string, password: string) => Promise<void>;
logout: () => void;
updatePassword: (currentPassword: string, newPassword: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [student, setStudent] = useState<Student | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [schoolLogo, setSchoolLogo] = useState<string | null>(null);
const fetchStudentData = useCallback(async (authToken: string) => {
try {
const res = await fetch('/api/portal/me', {
headers: { Authorization: `Bearer ${authToken}` },
});
if (res.ok) {
const data = await res.json();
setStudent(data.student);
}
} catch (err) {
console.error('Erro ao carregar dados do aluno:', err);
}
}, []);
useEffect(() => {
const savedToken = localStorage.getItem('portal_token');
const savedUser = localStorage.getItem('portal_user');
// Fetch School Logo for the portal
fetch('/api/portal/escola')
.then(res => res.json())
.then(data => {
if (data.logo) {
setSchoolLogo(data.logo);
// Set favicon dynamically
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = data.logo;
}
})
.catch(() => {});
if (savedToken && savedUser) {
try {
const parsed = JSON.parse(savedUser);
setToken(savedToken);
setUser(parsed);
fetchStudentData(savedToken);
} catch {
localStorage.removeItem('portal_token');
localStorage.removeItem('portal_user');
}
}
setIsLoading(false);
}, [fetchStudentData]);
const login = async (enrollmentNumber: string, password: string) => {
const res = await fetch('/api/portal/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enrollmentNumber, password }),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Credenciais inválidas');
}
const data = await res.json();
setToken(data.token);
setUser(data.user);
setStudent(data.student);
localStorage.setItem('portal_token', data.token);
localStorage.setItem('portal_user', JSON.stringify(data.user));
};
const logout = () => {
setToken(null);
setUser(null);
setStudent(null);
localStorage.removeItem('portal_token');
localStorage.removeItem('portal_user');
};
const updatePassword = async (currentPassword: string, newPassword: string) => {
const res = await fetch('/api/portal/alterar-senha', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Erro ao alterar senha');
}
};
return (
<AuthContext.Provider value={{ user, student, token, isLoading, schoolLogo, login, logout, updatePassword }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
}

View File

@ -0,0 +1,43 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const savedTheme = localStorage.getItem('portal_theme') as Theme | null;
if (savedTheme === 'light' || savedTheme === 'dark') {
setTheme(savedTheme);
document.documentElement.setAttribute('data-theme', savedTheme);
} else {
document.documentElement.setAttribute('data-theme', 'dark');
}
}, []);
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('portal_theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within a ThemeProvider');
return context;
}

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from 'react';
/**
* Hook that triggers a re-render every minute, returning the current Date.
* Useful for real-time UI updates (e.g. lesson status switching to 'Em Andamento' or 'Concluída').
*/
export function useRealTimeDate(intervalMs = 10000) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setNow(new Date());
}, intervalMs);
return () => clearInterval(timer);
}, [intervalMs]);
return now;
}

View File

@ -0,0 +1,132 @@
import { Lesson } from '../types';
/**
* Normaliza uma string de hora para formato HH:MM:SS
* Aceita: "14:00", "14:00:00", "9:30", etc.
*/
function normalizeTime(time: string): string {
if (!time || typeof time !== 'string') return '00:00:00';
const parts = time.trim().split(':');
const hours = (parts[0] || '00').padStart(2, '0');
const minutes = (parts[1] || '00').padStart(2, '0');
const seconds = (parts[2] || '00').padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* Retorna uma string YYYY-MM-DD de qualquer formato suportado
*/
export function getNormalizedDate(dateStr: string): string {
if (!dateStr || typeof dateStr !== 'string') return '';
const ms = parseLessonDateTime(dateStr, '12:00', 12);
if (isNaN(ms)) return '';
const d = new Date(ms);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseLessonDateTime(dateStr: string, timeStr?: string, defaultHour = 12): number {
if (!dateStr || typeof dateStr !== 'string') return NaN;
const trimmedDate = dateStr.trim();
const cleanDateStr = trimmedDate.substring(0, 10);
let y = 0, m = 0, d = 0;
if (cleanDateStr.includes('/')) {
const parts = cleanDateStr.split('/');
if (parts[2] && parts[2].length === 4) {
d = Number(parts[0]); m = Number(parts[1]) - 1; y = Number(parts[2]);
} else if (parts[0] && parts[0].length === 4) {
y = Number(parts[0]); m = Number(parts[1]) - 1; d = Number(parts[2]);
}
} else if (cleanDateStr.includes('-')) {
const parts = cleanDateStr.split('-');
if (parts[0] && parts[0].length === 4) {
y = Number(parts[0]); m = Number(parts[1]) - 1; d = Number(parts[2]);
} else {
d = Number(parts[0]) || 1; m = (Number(parts[1]) || 1) - 1; y = Number(parts[2]) || 2024;
}
}
if (isNaN(y) || isNaN(m) || isNaN(d) || y === 0) return NaN;
let h = defaultHour, min = 0, s = 0;
const trimmedTime = (timeStr || '').trim();
if (trimmedTime) {
const tParts = trimmedTime.split(':');
h = Number(tParts[0] || defaultHour);
min = Number(tParts[1] || 0);
s = Number(tParts[2] || 0);
} else {
if (defaultHour === 23) { h = 23; min = 59; s = 59; }
else { h = defaultHour; min = 0; s = 0; }
}
return new Date(y, m, d, h, min, s).getTime();
}
export function getLessonTimeStatus(lesson: Lesson, now = new Date()) {
let isCompleted = lesson.status === 'completed';
let isInProgress = false;
if (lesson.status === 'cancelled') {
return { isInProgress: false, isCompleted: true };
}
const dateRaw = lesson.date || '';
const lessonDateStr = typeof dateRaw === 'string' ? dateRaw.trim().substring(0, 10) : '';
if (!lessonDateStr) return { isInProgress, isCompleted };
const startRaw = lesson.startTime || (lesson as any).start_time || '';
const endRaw = lesson.endTime || (lesson as any).end_time || '';
const startTime = typeof startRaw === 'string' && startRaw.trim() ? startRaw : undefined;
const endTime = typeof endRaw === 'string' && endRaw.trim() ? endRaw : undefined;
let startMs = parseLessonDateTime(lessonDateStr, startTime, 0);
let endMs = parseLessonDateTime(lessonDateStr, endTime, 23);
// If endTime is missing, assume 1 hour duration
if (!endTime && !isNaN(startMs)) {
endMs = startMs + (60 * 60 * 1000);
}
const nowMs = now.getTime();
// If we couldn't parse dates, fallback
if (isNaN(startMs) || isNaN(endMs)) {
return { isInProgress, isCompleted };
}
// Check if current time is within bounds
if (nowMs >= startMs && nowMs <= endMs) {
isInProgress = true;
isCompleted = false;
} else if (nowMs > endMs) {
isCompleted = true;
}
return { isInProgress, isCompleted };
}
export function isLessonWithinJustificationWindow(lesson: Lesson, now = new Date()) {
if (!lesson || !lesson.date) return false;
const startTime = lesson.startTime || (lesson as any).start_time;
const endTime = lesson.endTime || (lesson as any).end_time;
const lessonStartMs = parseLessonDateTime(lesson.date, startTime, 0);
const lessonEndMs = parseLessonDateTime(lesson.date, endTime, 23);
if (isNaN(lessonStartMs) || isNaN(lessonEndMs)) return false;
const nowMs = now.getTime();
const dayInMs = 24 * 60 * 60 * 1000;
// Window: 24h before start until 24h after end
const canJustify = nowMs >= (lessonStartMs - dayInMs) && nowMs <= (lessonEndMs + dayInMs);
return canJustify;
}

19
portal/src/main.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<App />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,724 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import type { Exam, ExamSubmission } from '../types';
import {
ClipboardList, Clock, ChevronLeft, ChevronRight, Send, CheckCircle2,
XCircle, Award, AlertTriangle, Timer, ArrowLeft
} from 'lucide-react';
// ==========================================
// Exam Environment — Portal do Aluno
// ==========================================
type ExamView = 'listing' | 'exam' | 'result';
interface ExamResult {
total_questions: number;
correct_count: number;
wrong_count: number;
percentage: number;
final_score: number;
}
export default function Avaliacoes() {
const { token } = useAuth();
const [exams, setExams] = useState<Exam[]>([]);
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
const [loading, setLoading] = useState(true);
// Exam mode state
const [view, setView] = useState<ExamView>('listing');
const [activeExam, setActiveExam] = useState<Exam | null>(null);
const [currentQ, setCurrentQ] = useState(0);
const [answers, setAnswers] = useState<Record<string, number>>({});
const [timeLeft, setTimeLeft] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<ExamResult | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// In-app modal state (replaces native alert/confirm)
const [modalMsg, setModalMsg] = useState('');
const [modalType, setModalType] = useState<'info' | 'error' | 'confirm'>('info');
const [showModal, setShowModal] = useState(false);
const [confirmCallback, setConfirmCallback] = useState<(() => void) | null>(null);
const showAppAlert = (msg: string, type: 'info' | 'error' = 'info') => {
setModalMsg(msg);
setModalType(type);
setConfirmCallback(null);
setShowModal(true);
};
const showAppConfirm = (msg: string, onConfirm: () => void) => {
setModalMsg(msg);
setModalType('confirm');
setConfirmCallback(() => onConfirm);
setShowModal(true);
};
// Fetch exams
const fetchExams = useCallback(async () => {
if (!token) return;
try {
const res = await fetch('/api/portal/avaliacoes', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setExams(data.exams || []);
setSubmissions(data.submissions || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchExams();
}, [fetchExams]);
// Timer logic
useEffect(() => {
if (view !== 'exam' || timeLeft <= 0) return;
timerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
// Auto-submit when time runs out
clearInterval(timerRef.current!);
handleSubmit(true);
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [view, timeLeft > 0]);
const formatTimer = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
// Start exam
const startExam = (exam: Exam) => {
setActiveExam(exam);
setCurrentQ(0);
setAnswers({});
setTimeLeft(exam.durationMinutes * 60);
setResult(null);
setView('exam');
};
// Submit exam
const handleSubmit = async (autoSubmit = false) => {
if (submitting || !activeExam) return;
setSubmitting(true);
if (timerRef.current) clearInterval(timerRef.current);
try {
const res = await fetch('/api/portal/avaliacoes/submeter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ examId: activeExam.id, answers }),
});
const data = await res.json();
if (data.success) {
setResult(data.result);
setView('result');
fetchExams();
} else {
showAppAlert(data.error || 'Erro ao enviar prova.', 'error');
if (!autoSubmit) setView('listing');
}
} catch (err) {
console.error(err);
showAppAlert('Erro de conexão ao enviar prova.', 'error');
} finally {
setSubmitting(false);
}
};
const selectAnswer = (questionId: string, optionIndex: number) => {
setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
};
const getSubmission = (examId: string) =>
submissions.find(s => s.exam_id === examId);
// ==========================================
// RENDER: Listing
// ==========================================
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 250, height: 32, marginBottom: 24 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1rem' }}>
{[1, 2, 3].map(i => (
<div key={i} className="skeleton" style={{ height: 200, borderRadius: 16 }} />
))}
</div>
</div>
);
}
// ==========================================
// RENDER: Exam Mode (Focused)
// ==========================================
if (view === 'exam' && activeExam) {
const questions = activeExam.questions || [];
const question = questions[currentQ];
const totalQ = questions.length;
const answeredCount = Object.keys(answers).length;
const isUrgent = timeLeft <= 60;
const progress = totalQ > 0 ? ((currentQ + 1) / totalQ) * 100 : 0;
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'var(--color-surface)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Exam Header */}
<div style={{
padding: '1rem 1.5rem',
background: 'var(--glass-bg)',
borderBottom: '1px solid var(--glass-border)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<ClipboardList size={24} color="var(--color-primary)" />
<div>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>{activeExam.title}</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
Questão {currentQ + 1} de {totalQ} {answeredCount}/{totalQ} respondidas
</p>
</div>
</div>
{/* Timer */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 1rem', borderRadius: 12,
background: isUrgent ? 'rgba(239, 68, 68, 0.15)' : 'var(--bg-primary-alpha)',
border: `1px solid ${isUrgent ? 'var(--color-danger)' : 'var(--color-primary-alpha)'}`,
animation: isUrgent ? 'pulse 1s infinite' : undefined,
}}>
<Timer size={18} color={isUrgent ? 'var(--color-danger)' : 'var(--color-primary)'} />
<span style={{
fontSize: '1.25rem', fontWeight: 800, fontFamily: 'monospace',
color: isUrgent ? 'var(--color-danger)' : 'var(--color-text)',
}}>
{formatTimer(timeLeft)}
</span>
</div>
</div>
{/* Progress Bar */}
<div style={{ height: 4, background: 'var(--glass-border)' }}>
<div style={{
height: '100%', width: `${progress}%`,
background: 'var(--gradient-primary)',
transition: 'width 0.3s ease',
}} />
</div>
{/* Question Area */}
<div style={{
flex: 1, overflow: 'auto',
display: 'flex', justifyContent: 'center', alignItems: 'flex-start',
padding: '2rem 1.5rem',
}}>
<div style={{ maxWidth: 700, width: '100%' }}>
{question && (
<div className="animate-fade-in" key={question.id}>
{/* Question Text & Image */}
<div className="glass-card" style={{
padding: '2rem', marginBottom: '1.5rem',
borderLeft: '4px solid var(--color-primary)',
}}>
<span style={{
display: 'inline-block', fontSize: '0.7rem', fontWeight: 700,
background: 'var(--bg-primary-alpha)', color: 'var(--color-primary)',
padding: '2px 10px', borderRadius: 20, marginBottom: '1rem',
}}>
QUESTÃO {currentQ + 1}
</span>
<p style={{ fontSize: '1.05rem', fontWeight: 500, lineHeight: 1.6, marginBottom: question.imageUrl ? '1.5rem' : 0 }}>
{question.text}
</p>
{question.imageUrl && (
<div style={{
marginTop: '1.5rem',
borderRadius: 12,
overflow: 'hidden',
border: '2px solid var(--glass-border)',
background: 'white',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}>
<div style={{ padding: '8px 12px', background: 'var(--bg-primary-alpha)', borderBottom: '1px solid var(--glass-border)', fontSize: '0.65rem', fontWeight: 800, color: 'var(--color-primary)', textTransform: 'uppercase', letterSpacing: '1px' }}>
Imagem de Apoio
</div>
<img
src={question.imageUrl}
alt="Imagem de apoio"
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'zoom-in' }}
onClick={() => window.open(question.imageUrl, '_blank')}
/>
</div>
)}
</div>
{/* Options */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{question.options.map((opt, idx) => {
const isSelected = answers[question.id] === idx;
const letter = String.fromCharCode(65 + idx); // A, B, C, D...
return (
<button
key={idx}
onClick={() => selectAnswer(question.id, idx)}
className="glass-card"
style={{
padding: '1rem 1.25rem',
display: 'flex', alignItems: 'center', gap: '1rem',
cursor: 'pointer', border: 'none', textAlign: 'left',
outline: isSelected ? '2px solid var(--color-primary)' : 'none',
background: isSelected
? 'var(--bg-primary-alpha)'
: 'var(--color-surface-light)',
transition: 'all 0.2s ease',
borderRadius: 12,
}}
>
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.875rem', fontWeight: 700, flexShrink: 0,
background: isSelected ? 'var(--color-primary)' : 'var(--glass-border)',
color: isSelected ? 'white' : 'var(--color-text-secondary)',
transition: 'all 0.2s ease',
}}>
{letter}
</div>
<span style={{
fontSize: '0.9rem', fontWeight: isSelected ? 600 : 400,
color: isSelected ? 'var(--color-text)' : 'var(--color-text-secondary)',
}}>
{opt}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Navigation Footer */}
<div style={{
padding: '1rem 1.5rem',
background: 'var(--glass-bg)',
borderTop: '1px solid var(--glass-border)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
backdropFilter: 'blur(12px)',
}}>
<button
onClick={() => setCurrentQ(prev => Math.max(0, prev - 1))}
disabled={currentQ === 0}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '0.6rem 1.25rem', borderRadius: 10,
border: '1px solid var(--glass-border)',
background: 'var(--color-surface-light)',
color: currentQ === 0 ? 'var(--color-text-secondary)' : 'var(--color-text)',
cursor: currentQ === 0 ? 'not-allowed' : 'pointer',
fontWeight: 600, fontSize: '0.85rem',
opacity: currentQ === 0 ? 0.5 : 1,
}}
>
<ChevronLeft size={18} /> Anterior
</button>
{/* Question dots */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}>
{questions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentQ(i)}
style={{
width: 28, height: 28, borderRadius: '50%',
border: 'none', cursor: 'pointer',
fontSize: '0.7rem', fontWeight: 700,
background: i === currentQ
? 'var(--color-primary)'
: answers[q.id] !== undefined
? 'var(--color-success)'
: 'var(--glass-border)',
color: (i === currentQ || answers[q.id] !== undefined) ? 'white' : 'var(--color-text-secondary)',
transition: 'all 0.2s',
}}
>
{i + 1}
</button>
))}
</div>
{currentQ < totalQ - 1 ? (
<button
onClick={() => setCurrentQ(prev => Math.min(totalQ - 1, prev + 1))}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '0.6rem 1.25rem', borderRadius: 10,
border: 'none',
background: 'var(--color-primary)',
color: 'white',
cursor: 'pointer',
fontWeight: 600, fontSize: '0.85rem',
}}
>
Próxima <ChevronRight size={18} />
</button>
) : (
<button
onClick={() => {
if (answeredCount < totalQ) {
showAppConfirm(
`Você respondeu ${answeredCount} de ${totalQ} questões. Deseja finalizar mesmo assim?`,
() => handleSubmit()
);
} else {
handleSubmit();
}
}}
disabled={submitting}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '0.6rem 1.5rem', borderRadius: 10,
border: 'none',
background: 'var(--color-success)',
color: 'white',
cursor: submitting ? 'not-allowed' : 'pointer',
fontWeight: 700, fontSize: '0.85rem',
boxShadow: '0 4px 12px var(--bg-success-alpha)',
}}
>
<Send size={16} /> {submitting ? 'Enviando...' : 'Finalizar Prova'}
</button>
)}
</div>
{/* In-App Modal */}
{showModal && (
<div style={{
position: 'fixed', inset: 0, zIndex: 99999,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '1rem',
}}>
<div className="glass-card animate-scale-in" style={{
maxWidth: 400, width: '100%', padding: '2rem', textAlign: 'center',
background: 'var(--color-surface)',
}}>
<div style={{
width: 56, height: 56, borderRadius: '50%', margin: '0 auto 1rem',
background: modalType === 'error' ? 'var(--bg-danger-alpha)' : modalType === 'confirm' ? 'var(--bg-warning-alpha)' : 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{modalType === 'error'
? <XCircle size={28} color="var(--color-danger)" />
: modalType === 'confirm'
? <AlertTriangle size={28} color="var(--color-warning)" />
: <CheckCircle2 size={28} color="var(--color-primary)" />
}
</div>
<p style={{ fontSize: '0.9rem', fontWeight: 500, marginBottom: '1.5rem', lineHeight: 1.5 }}>
{modalMsg}
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
{modalType === 'confirm' ? (
<>
<button
onClick={() => setShowModal(false)}
style={{
flex: 1, padding: '0.65rem', borderRadius: 10,
border: '1px solid var(--glass-border)',
background: 'var(--color-surface-light)',
color: 'var(--color-text)', fontWeight: 600,
cursor: 'pointer', fontSize: '0.85rem',
}}
>
Cancelar
</button>
<button
onClick={() => { setShowModal(false); confirmCallback?.(); }}
style={{
flex: 1, padding: '0.65rem', borderRadius: 10,
border: 'none',
background: 'var(--color-success)',
color: 'white', fontWeight: 700,
cursor: 'pointer', fontSize: '0.85rem',
}}
>
Sim, Finalizar
</button>
</>
) : (
<button
onClick={() => setShowModal(false)}
style={{
width: '100%', padding: '0.65rem', borderRadius: 10,
border: 'none',
background: 'var(--color-primary)',
color: 'white', fontWeight: 700,
cursor: 'pointer', fontSize: '0.85rem',
}}
>
OK
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ==========================================
// RENDER: Result Screen
// ==========================================
if (view === 'result' && result) {
const isApproved = result.final_score >= 6;
const getMessage = () => {
if (result.percentage >= 90) return 'Excelente! Você arrasou! 🎉';
if (result.percentage >= 70) return 'Muito bem! Continue assim! 💪';
if (result.percentage >= 50) return 'Bom resultado. Pratique mais! 📚';
return 'Não desanime! Revise o conteúdo e tente novamente. 📖';
};
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'var(--color-surface)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '2rem',
}}>
<div className="glass-card animate-scale-in" style={{
maxWidth: 480, width: '100%', padding: '3rem 2rem', textAlign: 'center',
}}>
{/* Icon */}
<div style={{
width: 80, height: 80, borderRadius: '50%', margin: '0 auto 1.5rem',
background: isApproved ? 'var(--bg-success-alpha)' : 'var(--bg-danger-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isApproved
? <CheckCircle2 size={40} color="var(--color-success)" />
: <AlertTriangle size={40} color="var(--color-danger)" />
}
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 800, marginBottom: '0.5rem' }}>
Prova Finalizada!
</h2>
<p style={{ fontSize: '0.9rem', color: 'var(--color-text-secondary)', marginBottom: '2rem' }}>
{getMessage()}
</p>
{/* Score Display */}
<div style={{
display: 'flex', justifyContent: 'center', gap: '2rem',
marginBottom: '2rem', flexWrap: 'wrap',
}}>
<div>
<p style={{
fontSize: '3rem', fontWeight: 900, lineHeight: 1,
color: isApproved ? 'var(--color-success)' : 'var(--color-danger)',
}}>
{result.final_score.toFixed(1)}
</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>
NOTA FINAL
</p>
</div>
<div style={{ width: 1, background: 'var(--glass-border)' }} />
<div>
<p style={{ fontSize: '3rem', fontWeight: 900, lineHeight: 1, color: 'var(--color-text)' }}>
{result.percentage.toFixed(0)}%
</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>
APROVEITAMENTO
</p>
</div>
</div>
{/* Details */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem',
marginBottom: '2rem',
}}>
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 700 }}>{result.total_questions}</p>
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>QUESTÕES</p>
</div>
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-success)' }}>{result.correct_count}</p>
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ACERTOS</p>
</div>
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-danger)' }}>{result.wrong_count}</p>
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ERROS</p>
</div>
</div>
<button
onClick={() => { setView('listing'); setActiveExam(null); setResult(null); }}
style={{
width: '100%', padding: '0.875rem',
borderRadius: 12, border: 'none',
background: 'var(--color-primary)', color: 'white',
fontSize: '0.9rem', fontWeight: 700,
cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<ArrowLeft size={18} /> Voltar às Avaliações
</button>
</div>
</div>
);
}
// ==========================================
// RENDER: Listing (Default)
// ==========================================
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Avaliações</h1>
<p className="page-subtitle">Provas e avaliações disponíveis para você</p>
</div>
{exams.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '4rem 2rem', textAlign: 'center',
color: 'var(--color-text-secondary)',
}}>
<ClipboardList size={56} style={{ opacity: 0.2, marginBottom: '1rem' }} />
<p style={{ fontSize: '1rem', fontWeight: 600 }}>Nenhuma avaliação disponível no momento.</p>
<p style={{ fontSize: '0.8rem', marginTop: 4 }}>As provas aparecerão aqui quando forem publicadas pelo professor.</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem',
}} className="animate-fade-in stagger-children">
{exams.map(exam => {
const sub = getSubmission(exam.id);
const isDone = !!sub;
return (
<div key={exam.id} className="glass-card" style={{
padding: '1.5rem',
borderTop: isDone ? '3px solid var(--color-success)' : '3px solid var(--color-primary)',
position: 'relative', overflow: 'hidden',
}}>
{isDone && (
<div style={{
position: 'absolute', top: 12, right: 12,
background: 'var(--bg-success-alpha)', color: 'var(--color-success)',
padding: '4px 10px', borderRadius: 20,
fontSize: '0.65rem', fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 4,
}}>
<CheckCircle2 size={12} /> REALIZADA
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}>
{exam.title}
</h3>
{exam.description && (
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-secondary)', lineHeight: 1.4 }}>
{exam.description}
</p>
)}
</div>
<div style={{
display: 'flex', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
}}>
<Clock size={14} /> {exam.durationMinutes} minutos
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
}}>
<ClipboardList size={14} /> {exam.questions.length} questões
</div>
</div>
{isDone ? (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', borderRadius: 10,
background: 'var(--bg-success-alpha)',
}}>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>SUA NOTA</p>
<p style={{ fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-success)' }}>
{sub!.final_score.toFixed(1)}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ACERTOS</p>
<p style={{ fontSize: '1rem', fontWeight: 700 }}>
{sub!.correct_count}/{sub!.total_questions}
</p>
</div>
</div>
) : (
<button
onClick={() => startExam(exam)}
style={{
width: '100%', padding: '0.75rem',
borderRadius: 10, border: 'none',
background: 'var(--gradient-primary)', color: 'white',
fontSize: '0.875rem', fontWeight: 700,
cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: '0 4px 15px var(--bg-primary-alpha)',
}}
>
<Award size={18} /> Iniciar Prova
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { Award, Download } from 'lucide-react';
import type { Certificate } from '../types';
export default function Certificados() {
const { token } = useAuth();
const [certificates, setCertificates] = useState<Certificate[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/portal/certificados', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setCertificates(data.certificates || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
const formatDate = (d: string) => {
const date = new Date(d);
return date.toLocaleDateString('pt-BR');
};
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 200, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 200, borderRadius: 16 }} />
</div>
);
}
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Certificados</h1>
<p className="page-subtitle">Certificados emitidos para você</p>
</div>
{certificates.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)',
}}>
<Award size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p>Nenhum certificado emitido ainda</p>
</div>
) : (
<div className="stagger-children" style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem',
}}>
{certificates.map(cert => (
<div key={cert.id} className="glass-card" style={{ padding: '1.5rem', textAlign: 'center' }}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'linear-gradient(135deg, var(--bg-warning-alpha) 0%, var(--bg-warning-alpha) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 1rem',
}}>
<Award size={32} color="var(--color-warning)" />
</div>
<h3 style={{ fontWeight: 600, fontSize: '1rem', marginBottom: '0.375rem' }}>
Certificado
</h3>
{cert.description && (
<p style={{
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
marginBottom: '0.75rem',
}}>
{cert.description}
</p>
)}
<p style={{ fontSize: '0.75rem', color: 'var(--color-accent)' }}>
Emitido em {formatDate(cert.issueDate)}
</p>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,230 @@
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import { FileText, Eye, Printer, X, FileSignature } from 'lucide-react';
import type { Contract } from '../types';
export default function Contratos() {
const { token } = useAuth();
const [contracts, setContracts] = useState<Contract[]>([]);
const [loading, setLoading] = useState(true);
const [viewingContract, setViewingContract] = useState<Contract | null>(null);
const printRef = useRef<HTMLDivElement>(null);
const handlePrint = () => {
if (!viewingContract) return;
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>${viewingContract.title}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
body {
font-family: 'Inter', sans-serif;
padding: 40px;
color: #1a1a1a;
line-height: 1.8;
text-align: justify;
white-space: pre-line;
font-size: 12pt;
}
p, div { margin-bottom: 1rem; text-indent: 2.5rem; }
h1, h2, h3, h4 {
text-align: center;
margin: 1.5rem 0 1rem;
text-indent: 0;
font-weight: 700;
text-transform: uppercase;
}
ul, ol { padding-left: 3rem; margin-bottom: 1rem; }
li { margin-bottom: 0.5rem; }
@page { margin: 2cm; }
</style>
</head>
<body>${viewingContract.content}</body>
</html>
`);
printWindow.document.close();
// Wait for font loading
setTimeout(() => {
printWindow.print();
}, 500);
}
};
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/portal/contratos', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setContracts(data.contracts || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
const handleOpenModal = (contract: Contract) => {
setViewingContract(contract);
};
const closeModal = () => {
setViewingContract(null);
};
const formatDate = (d: string) => {
const date = new Date(d);
return date.toLocaleDateString('pt-BR');
};
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 200, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 200, borderRadius: 16 }} />
</div>
);
}
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Contratos</h1>
<p className="page-subtitle">Seus contratos com a instituição</p>
</div>
{contracts.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)',
}}>
<FileText size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p>Nenhum contrato encontrado</p>
</div>
) : (
<div className="stagger-children" style={{
display: 'grid', gap: '1rem',
}}>
{contracts.map(contract => (
<div key={contract.id} className="glass-card" style={{
padding: '1.5rem', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<FileText size={24} color="var(--color-primary-light)" />
</div>
<div>
<h3 style={{ fontWeight: 600, fontSize: '0.9375rem' }}>{contract.title}</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: 2 }}>
Emitido em {formatDate(contract.createdAt)}
</p>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn-primary"
onClick={() => handleOpenModal(contract)}
style={{ fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '0.35rem' }}
>
<FileSignature size={16} /> Ver Contrato Atual
</button>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{viewingContract && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 2000, padding: '1rem',
}}
onClick={closeModal}
>
<div className="glass-card animate-scale-in" style={{
width: '100%', maxWidth: '896px', height: '80vh',
display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--glass-border)',
}}>
<h3 style={{ fontWeight: 600 }}>{viewingContract.title}</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn-secondary" onClick={handlePrint} style={{ fontSize: '0.8125rem' }}>
<Printer size={16} /> Imprimir
</button>
<button
onClick={closeModal}
style={{
width: 36, height: 36, borderRadius: 10, border: 'none',
background: 'var(--color-surface-lighter)', color: 'var(--color-text)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<X size={18} />
</button>
</div>
</div>
<style>{`
.contract-content p,
.contract-content div {
margin-bottom: 1rem;
text-indent: 2rem;
}
.contract-content br + br {
display: block;
margin-top: 0.75rem;
content: '';
}
.contract-content h1,
.contract-content h2,
.contract-content h3,
.contract-content h4 {
text-align: center;
margin: 1.5rem 0 1rem;
text-indent: 0;
font-weight: 700;
}
.contract-content ul,
.contract-content ol {
padding-left: 2.5rem;
margin-bottom: 1rem;
}
.contract-content li {
margin-bottom: 0.5rem;
}
`}</style>
<div
ref={printRef}
className="contract-content"
style={{
padding: '2rem', overflow: 'auto', flex: 1,
fontSize: '0.9375rem', lineHeight: 1.8,
color: 'var(--color-text-secondary)',
textAlign: 'justify',
whiteSpace: 'pre-line',
}}
dangerouslySetInnerHTML={{ __html: viewingContract.content }}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,454 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { CreditCard, CalendarCheck, BookOpen, Clock, TrendingUp, AlertTriangle, CalendarClock } from 'lucide-react';
import type { Payment, Attendance, Class, Course, Lesson } from '../types';
import { getLessonTimeStatus, getNormalizedDate, parseLessonDateTime } from '../lib/lessonUtils';
import { useRealTimeDate } from '../hooks/useRealTimeDate';
interface DashboardData {
payments: Payment[];
attendance: Attendance[];
lessons: Lesson[];
studentClass: Class | null;
course: Course | null;
}
export default function Dashboard() {
const { student, token } = useAuth();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
// Real-time update every 10s
const now = useRealTimeDate(10000);
useEffect(() => {
const fetchAll = async () => {
try {
const headers = { Authorization: `Bearer ${token}` };
const [finRes, freqRes, meRes, aulasRes] = await Promise.all([
fetch('/api/portal/financeiro', { headers }),
fetch('/api/portal/frequencia', { headers }),
fetch('/api/portal/me', { headers }),
fetch('/api/portal/aulas', { headers }),
]);
const finData = await finRes.json();
const freqData = await freqRes.json();
const meData = await meRes.json();
const aulasData = await aulasRes.json();
setData({
payments: finData.payments || [],
attendance: freqData.attendance || [],
lessons: aulasData.lessons || [],
studentClass: meData.class || null,
course: meData.course || null,
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchAll();
}, [token]);
if (loading) {
return (
<div className="page-container stagger-children">
<div className="skeleton" style={{ width: 300, height: 32, marginBottom: 24 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} className="skeleton" style={{ height: 140, borderRadius: 16 }} />
))}
</div>
</div>
);
}
const pendingPayments = data?.payments.filter(p => p.status === 'pending' || p.status === 'overdue') || [];
const overduePayments = data?.payments.filter(p => p.status === 'overdue') || [];
const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0);
const totalAttendance = data?.attendance.length || 0;
const totalCourseLessons = data?.lessons.length || 0;
const presences = data?.attendance.filter(a => a.type === 'presence').length || 0;
const frequencyPercent = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0;
const nextDue = pendingPayments
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0];
const formatCurrency = (val: number) =>
val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const formatDate = (d: string) => {
if (!d) return '—';
const ms = parseLessonDateTime(d, '12:00', 12);
if (isNaN(ms)) return d;
return new Date(ms).toLocaleDateString('pt-BR');
};
const greeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Bom dia';
if (hour < 18) return 'Boa tarde';
return 'Boa noite';
};
const getNext7DaysReplacements = () => {
if (!data?.lessons) return [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7Days = new Date(today);
in7Days.setDate(in7Days.getDate() + 7);
return data.lessons.filter(l => {
if (l.status === 'cancelled') return false;
const parsedMs = parseLessonDateTime(l.date, '00:00', 0);
if (isNaN(parsedMs)) return false;
const classDate = new Date(parsedMs);
classDate.setHours(0, 0, 0, 0);
return l.type === 'reposicao' && classDate >= today && classDate <= in7Days;
});
};
const getNextOrCurrentClass = (): { lesson: Lesson; isInProgress: boolean } | null => {
if (!data?.lessons || data.lessons.length === 0) return null;
const activeLessons = data.lessons.filter(l => l.status !== 'cancelled');
// Normalize "now" date
const nowNorm = getNormalizedDate(now.toISOString());
// 1. First, priority: anything strictly "In Progress" RIGHT NOW
const currentlyPlaying = activeLessons.find(l => {
const { isInProgress } = getLessonTimeStatus(l, now);
return isInProgress;
});
if (currentlyPlaying) return { lesson: currentlyPlaying, isInProgress: true };
// 2. Secondary: If it's today and not completed yet
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const lessonsRemainingToday = activeLessons
.filter(l => {
const lessonMs = parseLessonDateTime(l.date, '12:00', 12);
const lessonDate = new Date(lessonMs);
lessonDate.setHours(0, 0, 0, 0);
const { isCompleted } = getLessonTimeStatus(l, now);
return lessonDate.getTime() === today.getTime() && !isCompleted;
})
.sort((a, b) => {
const timeA = parseLessonDateTime(a.date, a.startTime || (a as any).start_time, 0);
const timeB = parseLessonDateTime(b.date, b.startTime || (b as any).start_time, 0);
return timeA - timeB;
});
if (lessonsRemainingToday[0]) {
const { isInProgress } = getLessonTimeStatus(lessonsRemainingToday[0], now);
return { lesson: lessonsRemainingToday[0], isInProgress };
}
// 3. Last resort: Next future lesson
const nextFuture = activeLessons
.filter(l => {
const { isCompleted } = getLessonTimeStatus(l, now);
return !isCompleted;
})
.sort((a, b) => {
const dateA = parseLessonDateTime(a.date, a.startTime || (a as any).start_time, 0);
const dateB = parseLessonDateTime(b.date, b.startTime || (b as any).start_time, 0);
return dateA - dateB;
});
if (nextFuture[0]) {
const { isInProgress } = getLessonTimeStatus(nextFuture[0], now);
return { lesson: nextFuture[0], isInProgress };
}
return null;
};
const formatTime = (t?: string) => (t && typeof t === 'string') ? t.substring(0, 5) : '';
const replacements = getNext7DaysReplacements();
const nextClassInfo = getNextOrCurrentClass();
const nextClass = nextClassInfo?.lesson || null;
const isCurrentlyInProgress = nextClassInfo?.isInProgress || false;
return (
<div className="page-container">
<style>{`
@keyframes blink-status {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
`}</style>
{/* Greeting */}
<div className="animate-fade-in" style={{ marginBottom: '2rem' }}>
<h1 className="page-title">
{greeting()}, <span className="gradient-text">{student?.name.split(' ')[0]}</span>! 👋
</h1>
<p className="page-subtitle">
Aqui está um resumo da sua vida acadêmica.
</p>
{replacements.map(rep => (
<div key={rep.id} className="glass-card animate-fade-in" style={{
marginTop: '1.25rem', padding: '1rem',
background: 'var(--bg-success-alpha)', border: '1px solid var(--border-success-alpha)',
display: 'flex', alignItems: 'center', gap: '0.75rem',
color: 'var(--color-success)'
}}>
<CalendarClock size={20} />
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text)' }}>
🗓 <strong>Aviso:</strong> Você tem uma reposição agendada para o dia <strong>{formatDate(rep.date || '')}</strong>
{rep.startTime ? ` às ${formatTime(rep.startTime)}` : ''}.
</p>
</div>
))}
</div>
{/* Cards Grid */}
<div className="stagger-children" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem',
marginBottom: '2rem',
}}>
{/* Turma Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BookOpen size={22} color="var(--color-primary-light)" />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
MINHA TURMA
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{data?.studentClass?.name || 'Não vinculado'}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{data?.course?.name || '—'}
</p>
{data?.studentClass?.schedule && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-accent)', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={14} /> {data.studentClass.schedule}
</p>
)}
{data?.studentClass?.teacher && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--glass-border)' }}>
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}>
Professor Responsável
</p>
<p style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{data.studentClass.teacher}
</p>
</div>
)}
</div>
{/* Próxima Aula Card */}
<div className="glass-card" style={{
padding: '1.5rem',
border: isCurrentlyInProgress || nextClass?.type === 'extra' ? `2px solid ${nextClass?.type === 'extra' ? '#a855f7' : 'var(--color-info)'}` : undefined,
background: isCurrentlyInProgress
? (nextClass?.type === 'extra'
? 'linear-gradient(135deg, rgba(147, 51, 234, 0.25) 0%, rgba(168, 85, 247, 0.15) 100%)'
: nextClass?.type === 'reposicao'
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.25) 0%, rgba(22, 163, 74, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(99, 102, 241, 0.15) 100%)')
: undefined,
boxShadow: isCurrentlyInProgress
? (nextClass?.type === 'extra' ? '0 0 30px rgba(147, 51, 234, 0.2)' : nextClass?.type === 'reposicao' ? '0 0 30px rgba(34, 197, 94, 0.2)' : '0 0 30px rgba(59, 130, 246, 0.2)')
: undefined,
animation: isCurrentlyInProgress ? 'pulse-glow 3s infinite' : undefined,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: isCurrentlyInProgress
? (nextClass?.type === 'extra' ? 'rgba(147, 51, 234, 0.15)' : nextClass?.type === 'reposicao' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(59, 130, 246, 0.15)')
: 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: isCurrentlyInProgress ? 'pulse-glow 2s infinite' : undefined,
}}>
{isCurrentlyInProgress ? (
<Clock size={22} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'white'} />
) : (
<CalendarClock size={22} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-primary-light)'} />
)}
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
{isCurrentlyInProgress ? (
<>
<Clock size={14} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-info)'} className="animate-pulse" />
<span style={{ color: nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-info)', fontWeight: 700 }}>AULA EM ANDAMENTO</span>
</>
) : (
'PRÓXIMA AULA'
)}
</p>
</div>
</div>
{nextClass ? (
<>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, lineHeight: 1.2, color: isCurrentlyInProgress ? (nextClass.type === 'extra' ? '#a855f7' : nextClass.type === 'reposicao' ? '#22c55e' : 'var(--color-info)') : undefined }}>
{nextClass.status === 'rescheduled' ? 'Reagendada' : (nextClass.type === 'reposicao' ? 'Reposição' : (nextClass.type === 'extra' ? 'Aula Extra' : 'Aula Regular'))}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{formatDate(nextClass.date || '')}
</p>
{(nextClass.startTime || nextClass.endTime) && (
<p style={{ fontSize: '0.75rem', color: isCurrentlyInProgress ? 'var(--color-info)' : 'var(--color-accent)', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={14} /> {formatTime(nextClass.startTime)} {nextClass.endTime && `às ${formatTime(nextClass.endTime)}`}
</p>
)}
{isCurrentlyInProgress && (
<span className="animate-pulse" style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
marginTop: '0.75rem', padding: '4px 10px', borderRadius: 6,
background: nextClass.type === 'extra' ? '#a855f7' : nextClass.type === 'reposicao' ? '#22c55e' : 'var(--color-info)', color: 'white',
fontSize: '0.7rem', fontWeight: 600,
}}>
<Clock size={12} /> AULA EM ANDAMENTO
</span>
)}
</>
) : (
<>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, color: 'var(--color-text-secondary)' }}>
Nenhuma aula
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Você não possui próximas aulas
</p>
</>
)}
</div>
{/* Financeiro Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: overduePayments.length > 0 ? 'var(--bg-danger-alpha)' : 'var(--bg-success-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<CreditCard size={22} color={overduePayments.length > 0 ? 'var(--color-danger)' : 'var(--color-success)'} />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
FINANCEIRO
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{formatCurrency(totalPending)}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{pendingPayments.length} parcela{pendingPayments.length !== 1 ? 's' : ''} pendente{pendingPayments.length !== 1 ? 's' : ''}
</p>
{overduePayments.length > 0 && (
<p style={{
fontSize: '0.75rem', color: 'var(--color-danger)',
marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4,
}}>
<AlertTriangle size={14} /> {overduePayments.length} atrasada{overduePayments.length !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Frequência Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: frequencyPercent >= 75 ? 'var(--bg-accent-alpha)' : 'var(--bg-warning-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<CalendarCheck size={22} color={frequencyPercent >= 75 ? 'var(--color-accent-light)' : 'var(--color-warning)'} />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
FREQUÊNCIA
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{frequencyPercent}%
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{presences} presença{presences !== 1 ? 's' : ''} de {totalCourseLessons} aula{totalCourseLessons !== 1 ? 's' : ''} do curso
</p>
<div style={{
marginTop: '0.75rem', height: 6, borderRadius: 3,
background: 'var(--color-surface)',
}}>
<div style={{
height: '100%', borderRadius: 3,
width: `${frequencyPercent}%`,
background: frequencyPercent >= 75 ? 'var(--gradient-primary)' : 'var(--color-warning)',
transition: 'width 1s ease',
}} />
</div>
</div>
{/* Próximo Vencimento Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--bg-warning-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<TrendingUp size={22} color="var(--color-warning)" />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
PRÓXIMO VENCIMENTO
</p>
</div>
</div>
{nextDue ? (
<>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{formatCurrency(nextDue.amount - (nextDue.discount || 0))}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Vence em {formatDate(nextDue.dueDate)}
</p>
{nextDue.description && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-accent)', marginTop: '0.5rem' }}>
{nextDue.description}
</p>
)}
</>
) : (
<>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>
Em dia!
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Nenhuma parcela pendente
</p>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,443 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { ExternalLink, Filter, CreditCard, Printer, X } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import type { Payment, Boleto } from '../types';
type FilterType = 'all' | 'pending' | 'paid' | 'overdue';
export default function Financeiro() {
const { token } = useAuth();
const [searchParams] = useSearchParams();
const [payments, setPayments] = useState<Payment[]>([]);
const [boletos, setBoletos] = useState<Boleto[]>([]);
const [filter, setFilter] = useState<FilterType>((searchParams.get('filter') as FilterType) || 'all');
const [loading, setLoading] = useState(true);
const [receiptPayment, setReceiptPayment] = useState<Payment | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
const urlFilter = searchParams.get('filter') as FilterType;
if (urlFilter && ['all', 'pending', 'paid', 'overdue'].includes(urlFilter)) {
setFilter(urlFilter);
}
}, [searchParams]);
useEffect(() => {
const fetchData = async () => {
try {
const headers = { Authorization: `Bearer ${token}` };
const [payRes, bolRes] = await Promise.all([
fetch('/api/portal/financeiro', { headers }),
fetch('/api/portal/boletos', { headers }),
]);
const payData = await payRes.json();
const bolData = await bolRes.json();
const fetchedPayments = payData.payments || [];
const fetchedBoletos = bolData.boletos || [];
// We use only the detailed payments list (JSON source) as the primary data
// to show labels like "Parcela 1/3". The boletos (Supabase source) are
// kept only for the PDF link lookup in getBoletoLink.
setPayments(fetchedPayments);
setBoletos(fetchedBoletos);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
const normalizeStatus = (payment: Payment) => {
const s = payment.status?.toLowerCase();
if (['paid', 'received', 'confirmed', 'pago'].includes(s)) return 'paid';
if (['cancelled', 'cancelado'].includes(s)) return 'cancelled';
// Check if explicitly overdue in database
if (['overdue', 'atrasado', 'atrasada', 'vencido'].includes(s)) return 'overdue';
return 'pending';
};
const isPaid = (p: Payment) => normalizeStatus(p) === 'paid';
const isPending = (p: Payment) => ['pending', 'overdue'].includes(normalizeStatus(p));
const filtered = payments.filter(p => {
if (filter === 'all') return true;
return normalizeStatus(p) === filter;
});
const sorted = [...filtered].sort((a, b) => {
const dateA = new Date(a.dueDate).getTime();
const dateB = new Date(b.dueDate).getTime();
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
const formatCurrency = (val: number) =>
val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const formatDate = (d: string) => {
const date = new Date(d + 'T00:00:00');
return date.toLocaleDateString('pt-BR');
};
const getStatusBadge = (p: Payment) => {
const norm = normalizeStatus(p);
const map: Record<string, { className: string; label: string }> = {
paid: { className: 'badge badge-success', label: 'Pago' },
pending: { className: 'badge badge-warning', label: 'Pendente' },
overdue: { className: 'badge badge-danger', label: 'Atrasado' },
cancelled: { className: 'badge badge-info', label: 'Cancelado' },
};
const s = map[norm] || { className: 'badge badge-info', label: status };
return <span className={s.className}>{s.label}</span>;
};
const getReceiptLink = (payment: Payment): string | null => {
if ((payment as any).transactionReceiptUrl) return (payment as any).transactionReceiptUrl;
if ((payment as any).transaction_receipt_url) return (payment as any).transaction_receipt_url;
const asaasId = payment.asaasPaymentId || (payment as any).asaas_payment_id;
let boleto = null;
if (asaasId) {
boleto = boletos.find(b => (b as any).asaas_payment_id === asaasId);
}
if (!boleto) {
boleto = boletos.find(b =>
(b as any).vencimento === payment.dueDate &&
Math.abs(Number((b as any).valor) - (payment.amount - (payment.discount || 0))) < 1
);
}
if (!boleto) return null;
return (boleto as any).link_recibo || (boleto as any).transaction_receipt_url || null;
};
const handleOpenReceipt = (payment: Payment) => {
const receiptUrl = getReceiptLink(payment);
if (receiptUrl) {
window.open(receiptUrl, '_blank', 'noopener,noreferrer');
} else {
setReceiptPayment(payment);
}
};
const getBoletoLink = (payment: Payment) => {
if (payment.asaasPaymentUrl) return payment.asaasPaymentUrl;
if ((payment as any).link_boleto) return (payment as any).link_boleto;
const asaasId = payment.asaasPaymentId || (payment as any).asaas_payment_id;
let boleto = null;
if (asaasId) {
boleto = boletos.find(b => (b as any).asaas_payment_id === asaasId);
}
if (!boleto) {
boleto = boletos.find(b =>
(b as any).vencimento === payment.dueDate &&
Math.abs(Number((b as any).valor) - (payment.amount - (payment.discount || 0))) < 1
);
}
return (boleto as any)?.link_boleto || null;
};
const getEffectiveValue = (payment: Payment) => {
const baseAmount = payment.amount || 0;
const discount = payment.discount || 0;
const netAmount = baseAmount - discount;
const status = normalizeStatus(payment);
// Try to find matching boleto from Supabase sync
const asaasId = payment.asaasPaymentId || (payment as any).asaas_payment_id;
let boleto = null;
if (asaasId) {
boleto = boletos.find(b => (b as any).asaas_payment_id === asaasId);
}
if (!boleto) {
// Fallback: Match by due date and base amount (allowing for interest/fines)
boleto = boletos.find(b => {
const bVenc = (b as any).vencimento;
const bVal = Number((b as any).valor);
// Exact date match
if (bVenc === payment.dueDate) {
// If value is exactly base or exactly net
if (Math.abs(bVal - baseAmount) < 1 || Math.abs(bVal - netAmount) < 1) return true;
// If it's overdue, the boleto value will be HIGHER than baseAmount
if (status === 'overdue' && bVal > netAmount) return true;
}
return false;
});
}
// If we have a boleto and it is overdue or paid, use current Asaas value
if (boleto && (boleto as any).valor) {
const bValue = Number((boleto as any).valor);
if (status === 'overdue' || status === 'paid') {
return bValue;
}
}
// Default: use the discounted base value (net amount)
return netAmount;
};
const totalPending = payments
.filter(p => isPending(p))
.reduce((s, p) => s + getEffectiveValue(p), 0);
const totalPaid = payments
.filter(p => isPaid(p))
.reduce((s, p) => s + getEffectiveValue(p), 0);
const filters: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'Todos' },
{ key: 'pending', label: 'Pendentes' },
{ key: 'paid', label: 'Pagos' },
{ key: 'overdue', label: 'Atrasados' },
];
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 200, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 400, borderRadius: 16 }} />
</div>
);
}
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Financeiro</h1>
<p className="page-subtitle">Acompanhe seus pagamentos e boletos</p>
</div>
{/* Summary Cards */}
<div className="stagger-children" style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: '1rem', marginBottom: '1.5rem',
}}>
<div className="glass-card" style={{ padding: '1.25rem' }}>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginBottom: '0.5rem' }}>
TOTAL EM ABERTO
</p>
<p style={{ fontSize: '1.375rem', fontWeight: 700, color: totalPending > 0 ? 'var(--color-warning)' : 'var(--color-success)' }}>
{formatCurrency(totalPending)}
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem' }}>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginBottom: '0.5rem' }}>
TOTAL PAGO
</p>
<p style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--color-success)' }}>
{formatCurrency(totalPaid)}
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem' }}>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginBottom: '0.5rem' }}>
TOTAL DE PARCELAS
</p>
<p style={{ fontSize: '1.375rem', fontWeight: 700 }}>
{payments.length}
</p>
</div>
</div>
{/* Filters */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
marginBottom: '1rem', flexWrap: 'wrap',
}}>
<Filter size={16} color="var(--color-text-secondary)" />
{filters.map(f => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{
padding: '0.4375rem 0.875rem', borderRadius: 9999,
border: '1px solid',
borderColor: filter === f.key ? 'var(--color-success)' : 'var(--color-border)',
background: filter === f.key ? 'var(--color-success)' : 'transparent',
color: filter === f.key ? 'white' : 'var(--color-text-secondary)',
fontSize: '0.8125rem', fontWeight: 600, cursor: 'pointer',
transition: 'all 0.2s ease', fontFamily: 'Inter, sans-serif',
boxShadow: filter === f.key ? '0 4px 12px var(--bg-success-alpha)' : 'none',
}}
>
{f.label}
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>ORDEM:</span>
<button
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
className="btn-secondary"
style={{ padding: '4px 12px', borderRadius: 8, fontSize: '0.75rem' }}
>
{sortOrder === 'asc' ? 'Crescente (Antigos)' : 'Decrescente (Novos)'}
</button>
</div>
</div>
{/* Table */}
<div className="glass-card animate-fade-in" style={{ overflow: 'hidden' }}>
{sorted.length === 0 ? (
<div style={{
padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)',
}}>
<CreditCard size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p style={{ fontSize: '0.9375rem' }}>Nenhum pagamento encontrado</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>Descrição</th>
<th>Vencimento</th>
<th>Valor</th>
<th>Desconto</th>
<th>A Pagar</th>
<th>Status</th>
<th>Ação</th>
</tr>
</thead>
<tbody>
{sorted.map((payment, idx) => {
const link = getBoletoLink(payment);
return (
<tr key={payment.id} style={{
animation: `fadeIn 0.3s ease-out ${idx * 0.04}s forwards`,
opacity: 0,
}}>
<td>
<div>
<p style={{ fontWeight: 500 }}>
{payment.description || `Parcela ${payment.installmentNumber || '—'}`}
</p>
{payment.totalInstallments && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{payment.installmentNumber}/{payment.totalInstallments}
</p>
)}
</div>
</td>
<td>{formatDate(payment.dueDate)}</td>
<td style={{ fontWeight: 500 }}>
{formatCurrency(payment.amount)}
</td>
<td style={{ color: payment.discount ? 'var(--color-success)' : 'var(--color-text-secondary)', fontSize: '0.8125rem' }}>
{payment.discount ? `- ${formatCurrency(payment.discount)}` : '—'}
</td>
<td style={{
fontWeight: 600,
color: normalizeStatus(payment) === 'overdue' ? 'var(--color-danger)' : 'var(--color-primary-light)'
}}>
{formatCurrency(getEffectiveValue(payment))}
</td>
<td data-label="Status">{getStatusBadge(payment)}</td>
<td>
{isPaid(payment) ? (
<button
onClick={() => handleOpenReceipt(payment)}
style={{
fontSize: '0.75rem', padding: '0.375rem 0.75rem',
display: 'flex', alignItems: 'center', gap: '0.35rem',
background: 'rgba(16, 185, 129, 0.1)', color: 'var(--color-success)',
border: '1px solid rgba(16, 185, 129, 0.3)',
cursor: 'pointer', borderRadius: 8, fontWeight: 600,
fontFamily: 'Inter, sans-serif',
transition: 'all 0.2s ease',
}}
>
<Printer size={14} /> Visualizar Recibo
</button>
) : isPending(payment.status) && link ? (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem', textDecoration: 'none' }}
>
<ExternalLink size={14} />
Ver Boleto
</a>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{receiptPayment && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 2000, padding: '1rem',
}} onClick={() => setReceiptPayment(null)}>
<div className="glass-card animate-scale-in" style={{
width: '100%', maxWidth: '500px', backgroundColor: 'var(--color-surface)',
padding: '2rem', display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', borderBottom: '1px solid var(--glass-border)', paddingBottom: '1rem' }}>
<h3 style={{ fontWeight: 700, fontSize: '1.25rem' }}>Recibo de Pagamento</h3>
<button onClick={() => setReceiptPayment(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-secondary)' }}>
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Referência:</span>
<span style={{ fontWeight: 600 }}>{receiptPayment.description || `Parcela ${receiptPayment.installmentNumber || '—'}`}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Valor Pago:</span>
<span style={{ fontWeight: 600 }}>{formatCurrency(receiptPayment.amount - (receiptPayment.discount || 0))}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Data de Vencimento:</span>
<span style={{ fontWeight: 600 }}>{formatDate(receiptPayment.dueDate)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Status:</span>
<span style={{ fontWeight: 600, color: 'var(--color-success)' }}>Quitado</span>
</div>
{receiptPayment.asaasPaymentId && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Cód. Transação:</span>
<span style={{ fontWeight: 600, fontSize: '0.8125rem' }}>{receiptPayment.asaasPaymentId}</span>
</div>
)}
</div>
<button
onClick={() => window.print()}
className="btn-primary"
style={{ padding: '0.75rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', fontWeight: 600 }}
>
<Printer size={18} /> Imprimir Comprovante
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,782 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { CalendarCheck, CheckCircle2, XCircle, FileText, Send, X, Loader2, AlertTriangle, ChevronDown, Clock } from 'lucide-react';
import type { Attendance, Lesson } from '../types';
import { getLessonTimeStatus, getNormalizedDate, isLessonWithinJustificationWindow, parseLessonDateTime } from '../lib/lessonUtils';
import { useRealTimeDate } from '../hooks/useRealTimeDate';
export default function Frequencia() {
const { token } = useAuth();
const [attendance, setAttendance] = useState<Attendance[]>([]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [activeTab, setActiveTab] = useState<'scheduled' | 'history'>('scheduled');
const [loading, setLoading] = useState(true);
const [successMsg, setSuccessMsg] = useState('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Modal State
const [showJustifyModal, setShowJustifyModal] = useState(false);
const [selectedDate, setSelectedDate] = useState('');
const [justificationText, setJustificationText] = useState('');
const [justificationFile, setJustificationFile] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
// Update time every 10s to keep timeline ticking forward live
// This hook MUST be unconditionally called at the top level
const now = useRealTimeDate(10000);
useEffect(() => {
const fetchData = async () => {
try {
const headers = { Authorization: `Bearer ${token}` };
const [freqRes, aulasRes] = await Promise.all([
fetch('/api/portal/frequencia', { headers }),
fetch('/api/portal/aulas', { headers })
]);
const freqData = await freqRes.json();
const aulasData = await aulasRes.json();
setAttendance(freqData.attendance || []);
setLessons(aulasData.lessons || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
const openJustifyModal = (preselectedTimestamp?: string) => {
setShowJustifyModal(true);
setSelectedDate(preselectedTimestamp || '');
setJustificationText('');
setJustificationFile(null);
setError('');
setSuccessMsg('');
};
const closeModal = () => {
setShowJustifyModal(false);
setSelectedDate('');
setJustificationText('');
setJustificationFile(null);
setError('');
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Se não for imagem, apenas lê normalmente (ex: PDF)
if (!file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => setJustificationFile(reader.result as string);
reader.readAsDataURL(file);
return;
}
// Lógica de Compactação para Imagens
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Limite máximo de 1280px para largura ou altura
const MAX_SIZE = 1280;
if (width > height) {
if (width > MAX_SIZE) {
height *= MAX_SIZE / width;
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width *= MAX_SIZE / height;
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
// Exporta como JPEG com 70% de qualidade para o melhor balanço tamanho/qualidade
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7);
setJustificationFile(compressedBase64);
}
};
img.src = event.target?.result as string;
};
reader.readAsDataURL(file);
};
const handleJustify = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDate) {
setError('Selecione a data da aula');
return;
}
if (!justificationText.trim()) {
setError('A justificativa é obrigatória');
return;
}
setSubmitLoading(true);
setError('');
const payload: any = { motivo: justificationText.trim() };
if (justificationFile) {
payload.arquivo_base64 = justificationFile;
}
try {
const res = await fetch('/api/portal/frequencia/justificar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
date: selectedDate,
justification: JSON.stringify(payload),
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Erro ao enviar justificativa');
}
const { record } = await res.json();
// Update local state
setAttendance(prev => {
const exists = prev.find(a => a.id === record.id);
if (exists) return prev.map(a => a.id === record.id ? record : a);
return [...prev, record];
});
closeModal();
setSuccessMsg(`Justificativa enviada com sucesso para o dia ${formatDate(selectedDate)}!`);
setTimeout(() => setSuccessMsg(''), 5000);
} catch (err: any) {
setError(err.message || 'Erro ao comunicar com o servidor');
} finally {
setSubmitLoading(false);
}
};
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 200, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 16 }} />
</div>
);
}
// Stats calculation (based on total course schedule vs presences)
const totalCourseLessons = lessons.length;
const presences = attendance.filter(a => a.type === 'presence').length;
const absences = attendance.filter(a => a.type === 'absence').length;
const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0;
// Merge and Categorize
const processedItems = lessons.map(lesson => {
const lessonFullISO = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString();
const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00');
const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '00:00:00', lesson.endTime ? 0 : 60);
const atts = attendance.filter(a => {
if (!a.date || typeof a.date !== 'string') return false;
// 1. Exact Match (Best case)
if (a.date === lessonFullISO) return true;
const attMs = new Date(a.date).getTime();
// 2. Presence Match (Biometrics)
// Allow any presence within the lesson duration (+ buffer)
if (a.type === 'presence') {
return attMs >= (lessonStartMs - 10 * 60000) && attMs <= (lessonEndMs + 5 * 60000);
}
// 3. Justification Proximity Match (Strict 10 mins from start)
const diffMinutes = Math.abs(attMs - lessonStartMs) / (1000 * 60);
return diffMinutes <= 10;
});
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
return {
lesson,
attendances: atts,
isInProgress,
isCompleted
};
});
const activeItems = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').sort((a, b) => {
const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0);
const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0);
return dateA - dateB;
});
const historyItems = processedItems.filter(item => item.isCompleted || item.lesson.status === 'cancelled').sort((a, b) => {
const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0);
const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0);
return dateB - dateA; // History descending
});
const displayItems = activeTab === 'scheduled' ? activeItems : historyItems;
// Collect lessons available for justification modal dropdown
const justifiableLessons = lessons.filter(l => {
if (l.status === 'cancelled') return false;
// Check window (uses new 24h before/after logic)
if (!isLessonWithinJustificationWindow(l, now)) return false;
const lessonFullISO = new Date(parseLessonDateTime(l.date, l.startTime || '00:00:00')).toISOString();
const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00:00');
const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '00:00:00', l.endTime ? 0 : 60);
// Find if THIS SPECIFIC lesson has attendance/justification
const att = attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false;
const attMs = new Date(a.date).getTime();
// Strict match by ISO or within duration for presence
if (a.date === lessonFullISO) return true;
if (a.type === 'presence' && attMs >= (lessonStartMs - 10 * 60000) && attMs <= (lessonEndMs + 5 * 60000)) return true;
return false;
});
if (att) {
if (att.type === 'presence' || att.verified) return false;
if (att.justification) return false;
}
return true;
});
const formatDate = (d: string) => {
try {
const ms = parseLessonDateTime(d, '12:00', 12);
if (isNaN(ms)) return d;
return new Date(ms).toLocaleDateString('pt-BR');
} catch {
return d;
}
};
const formatDateFull = (d: string) => {
try {
const ms = parseLessonDateTime(d, '12:00', 12);
if (isNaN(ms)) return d;
return new Date(ms).toLocaleDateString('pt-BR', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
});
} catch {
return d;
}
};
const parseJustification = (j?: string): string | null => {
if (!j) return null;
try {
const parsed = JSON.parse(j);
return parsed.motivo || j;
} catch {
return j;
}
};
return (
<div className="page-container">
<style>{`
@keyframes blink-status {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
`}</style>
<div className="animate-fade-in" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h1 className="page-title">Frequência</h1>
<p className="page-subtitle">Acompanhe seu histórico de presença e justificativas</p>
</div>
</div>
</div>
{/* Success message */}
{successMsg && (
<div className="animate-fade-in" style={{
background: 'var(--bg-success-alpha)', border: '1px solid var(--border-success-alpha)',
borderRadius: 12, padding: '1rem', marginBottom: '1rem',
color: 'var(--color-success)', fontSize: '0.875rem', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
{successMsg}
</div>
)}
{/* Stats */}
<div className="stagger-children" style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem', marginBottom: '1.5rem',
}}>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
<div style={{
width: 64, height: 64, borderRadius: '50%', margin: '0 auto 0.75rem',
background: `conic-gradient(${percentage >= 75 ? 'var(--color-primary)' : 'var(--color-warning)'} ${percentage * 3.6}deg, var(--color-surface) 0deg)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 52, height: 52, borderRadius: '50%', background: 'var(--color-surface-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: '1rem',
}}>
{percentage}%
</div>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
FREQUÊNCIA TOTAL
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--color-success)' }}>{presences}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginTop: 4 }}>
PRESENÇAS
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '2rem', fontWeight: 700, color: 'var(--color-danger)' }}>{absences}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginTop: 4 }}>
FALTAS
</p>
</div>
<div className="glass-card" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '2rem', fontWeight: 700 }}>{totalCourseLessons}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, marginTop: 4 }}>
TOTAL DE AULAS DO CURSO
</p>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.5rem',
flexWrap: 'wrap'
}}>
<div className="glass-card" style={{ padding: '4px', display: 'flex', gap: '4px', borderRadius: 12 }}>
<button
onClick={() => setActiveTab('scheduled')}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s',
background: activeTab === 'scheduled' ? 'var(--color-primary)' : 'transparent',
color: activeTab === 'scheduled' ? 'white' : 'var(--color-text-secondary)',
}}
>
Próximas Aulas ({activeItems.length})
</button>
<button
onClick={() => setActiveTab('history')}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s',
background: activeTab === 'history' ? 'var(--color-primary)' : 'transparent',
color: activeTab === 'history' ? 'white' : 'var(--color-text-secondary)',
}}
>
Histórico ({historyItems.length})
</button>
</div>
<button
onClick={() => openJustifyModal()}
className="btn-primary"
style={{ padding: '0.5rem 1.25rem', borderRadius: 12, height: 'auto' }}
>
<Send size={18} /> Justificar Falta
</button>
</div>
{/* List */}
{displayItems.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)',
}}>
<CalendarCheck size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p>Nenhuma aula encontrada no cronograma</p>
</div>
) : (
<div className="glass-card animate-fade-in" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>Data</th>
<th>Horário</th>
<th>Status de Aula</th>
<th>Presença</th>
<th>Hora Presença</th>
<th>Justificativa</th>
<th>Texto da Justificativa</th>
</tr>
</thead>
<tbody>
{displayItems.map((item, idx) => {
const { lesson, attendances: atts, isInProgress, isCompleted } = item;
const isCancelled = lesson.status === 'cancelled';
const isRescheduled = lesson.status === 'rescheduled';
// PREREQUISITE: 'presence' type OR verified status counts as real presence
const isPresent = atts.some(a => a.type === 'presence' || a.verified === true);
const hasJustification = atts.some(a => !!a.justification);
const activeJustification = atts.find(a => !!a.justification);
const justText = parseJustification(activeJustification?.justification);
const isJustificationAccepted = activeJustification?.justificationAccepted === true;
const isWithinWindow = isLessonWithinJustificationWindow(lesson, now);
const canJustify = !isPresent && isWithinWindow && !justText && lesson.status !== 'cancelled';
return (
<tr key={lesson.id} style={{
animation: `fadeIn 0.3s ease-out ${idx * 0.03}s forwards`,
opacity: 0,
backgroundColor: isJustificationAccepted ? 'rgba(245, 158, 11, 0.08)' : 'transparent',
}}>
<td>{formatDateFull(lesson.date)}</td>
<td>
{typeof lesson.startTime === 'string' ? (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
{lesson.startTime.substring(0, 5)}{typeof lesson.endTime === 'string' ? ` - ${lesson.endTime.substring(0, 5)}` : ''}
</span>
) : (
<span style={{ color: 'var(--color-text-secondary)' }}></span>
)}
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: '4px' }}>
{isInProgress && (
<span className="animate-pulse" style={{
background: lesson.type === 'extra' ? '#a855f7' : 'var(--color-info)', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content'
}}>
<Clock size={12} /> AULA EM ANDAMENTO
</span>
)}
{isCancelled ? (
<span style={{
background: 'var(--color-danger)', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600, width: 'fit-content'
}}>
CANCELADA
</span>
) : isRescheduled ? (
<span style={{
background: '#8b5cf6', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600, width: 'fit-content'
}}>
REAGENDADA
</span>
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) ? (
<span style={{
background: 'var(--color-success)', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content'
}}>
<CheckCircle2 size={12} /> CONCLUÍDA
</span>
) : (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<span style={{
background: 'var(--bg-primary-alpha)', color: 'var(--color-primary)',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600, width: 'fit-content'
}}>
AGENDADA
</span>
{lesson.type === 'extra' && (
<span style={{
background: '#9333ea', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600, width: 'fit-content'
}}>
AULA EXTRA
</span>
)}
{lesson.type === 'reposicao' && (
<span style={{
background: '#22c55e', color: 'white',
padding: '4px 8px', borderRadius: 4, fontSize: '0.7rem', fontWeight: 600, width: 'fit-content'
}}>
REPOSIÇÃO
</span>
)}
</div>
)}
</div>
</td>
<td>
{isPresent ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}>
<CheckCircle2 size={16} /> Presente
</span>
) : isJustificationAccepted ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}>
<AlertTriangle size={16} /> Falta Justificada
</span>
) : hasJustification ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-info)', fontWeight: 500 }}>
<Clock size={16} /> Justificativa Pendente
</span>
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-danger)' }}>
<XCircle size={16} /> Falta
</span>
) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}>
Aguardando
</span>
)}
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{atts.length > 0 ? (
atts
.filter(a => a.type === 'presence' || a.verified)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((a, aIdx) => {
const d = new Date(a.date);
if (isNaN(d.getTime())) return null;
return (
<span key={a.id} style={{
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
fontWeight: 500,
display: 'block',
borderLeft: isCancelled ? '4px solid var(--color-danger)'
: isInProgress && !isCancelled ? '4px solid var(--color-info)'
: isRescheduled ? '4px solid #8b5cf6'
: isCompleted ? '4px solid var(--color-success)'
: '4px solid var(--color-primary)',
padding: '2px 6px',
borderRadius: 4,
width: 'fit-content'
}}>
{d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</span>
);
})
) : (
<span style={{ color: 'var(--color-text-secondary)' }}></span>
)}
{atts.length > 0 && atts.filter(a => a.type === 'presence' || a.verified).length === 0 && (
<span style={{ color: 'var(--color-text-secondary)' }}></span>
)}
</div>
</td>
<td>
{justText ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8125rem', color: isJustificationAccepted ? 'var(--color-success)' : 'var(--color-text-secondary)' }}>
<FileText size={14} color="currentColor" />
{isJustificationAccepted ? 'Justificativa Aceita' : 'Em Análise'}
</span>
) : canJustify ? (
<button
onClick={() => {
const timestamp = new Date(parseLessonDateTime(lesson.date, lesson.startTime || '00:00:00')).toISOString();
openJustifyModal(timestamp);
}}
style={{
fontSize: '0.75rem', padding: '0.375rem 0.75rem', borderRadius: 8,
background: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)',
border: '1px solid rgba(239, 68, 68, 0.3)',
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.35rem',
fontFamily: 'Inter, sans-serif', fontWeight: 500,
transition: 'all 0.2s ease',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'; }}
>
<Send size={14} />
Justificar
</button>
) : (
<span style={{ color: 'var(--color-text-secondary)' }}></span>
)}
</td>
<td>
{justText ? (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 250, display: 'block', wordBreak: 'break-word' }}>
{justText}
</span>
) : (
<span style={{ color: 'var(--color-text-secondary)' }}></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* ========== MODAL DE JUSTIFICATIVA ========== */}
{showJustifyModal && (
<div style={{
position: 'fixed', inset: 0, background: 'var(--overlay-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 2000, padding: '1rem',
}}>
<div className="glass-card animate-scale-in" style={{
width: '100%', maxWidth: 500, padding: '2rem',
background: 'var(--color-surface)',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: '1.5rem',
}}>
<div>
<h3 style={{ fontWeight: 700, fontSize: '1.1rem' }}>Justificar Falta</h3>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 2 }}>
Selecione a data e descreva o motivo
</p>
</div>
<button
onClick={closeModal}
style={{
background: 'var(--color-surface-light)', border: '1px solid var(--glass-border)',
borderRadius: 8, width: 32, height: 32, display: 'flex',
alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)',
}}
>
<X size={18} />
</button>
</div>
{error && (
<div style={{
background: 'var(--bg-danger-alpha)', color: 'var(--color-danger)',
padding: '0.75rem', borderRadius: 8, fontSize: '0.8125rem', marginBottom: '1rem',
}}>
{error}
</div>
)}
<form onSubmit={handleJustify} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', display: 'block' }}>
Data da aula *
</label>
<div style={{ position: 'relative' }}>
<select
className="input-field"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
style={{
appearance: 'none', paddingRight: '2.5rem',
cursor: 'pointer', width: '100%',
}}
>
<option value=""> Selecione a data da aula </option>
{justifiableLessons
.sort((a, b) => {
const msA = parseLessonDateTime(a.date, a.startTime);
const msB = parseLessonDateTime(b.date, b.startTime);
return (isNaN(msB) ? 0 : msB) - (isNaN(msA) ? 0 : msA);
})
.map(l => {
const ts = new Date(parseLessonDateTime(l.date, l.startTime || '00:00:00')).toISOString();
return (
<option key={l.id} value={ts}>
{formatDateFull(l.date)}{l.startTime ? `${l.startTime.substring(0, 5)}` : ''}{l.endTime ? ` às ${l.endTime.substring(0, 5)}` : ''}
</option>
);
})
}
</select>
<ChevronDown size={18} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
pointerEvents: 'none', color: 'var(--color-text-secondary)',
}} />
</div>
</div>
<div>
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', display: 'block' }}>
Motivo da ausência *
</label>
<textarea
className="input-field"
rows={4}
placeholder="Descreva o motivo da sua falta (ex: Atestado médico, problema familiar...)"
value={justificationText}
onChange={e => setJustificationText(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', display: 'block' }}>
Anexar documento (opcional)
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={handleFileChange}
className="input-field"
style={{ padding: '0.5rem' }}
/>
{justificationFile && (
<p style={{ fontSize: '0.7rem', color: 'var(--color-success)', marginTop: '0.25rem' }}>
Arquivo carregado com sucesso
</p>
)}
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', paddingTop: '0.5rem' }}>
<button
type="button"
className="btn-secondary"
onClick={closeModal}
disabled={submitLoading}
>
Cancelar
</button>
<button
type="submit"
className="btn-primary"
disabled={submitLoading}
style={{ minWidth: 120, display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: 'center' }}
>
{submitLoading ? (
<Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<><Send size={14} /> Enviar</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

214
portal/src/pages/Login.tsx Normal file
View File

@ -0,0 +1,214 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { GraduationCap, Eye, EyeOff, Loader2 } from 'lucide-react';
export default function Login() {
const [enrollment, setEnrollment] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [schoolLogo, setSchoolLogo] = useState<string | null>(null);
const [schoolName, setSchoolName] = useState('Portal do Aluno');
const { login } = useAuth();
const navigate = useNavigate();
useEffect(() => {
fetch('/api/portal/escola')
.then(res => res.json())
.then(data => {
if (data.logo) setSchoolLogo(data.logo);
if (data.name) setSchoolName(data.name);
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!enrollment.trim() || !password.trim()) {
setError('Preencha todos os campos');
return;
}
setLoading(true);
setError('');
try {
await login(enrollment.trim(), password);
navigate('/', { replace: true });
} catch (err: any) {
setError(err.message || 'Erro ao fazer login');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--gradient-login)',
padding: '1rem',
position: 'relative',
overflow: 'hidden',
}}>
{/* Background Orbs */}
<div style={{
position: 'absolute', width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, var(--bg-primary-alpha) 0%, transparent 70%)',
top: '-15%', left: '-10%', pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, var(--bg-accent-alpha) 0%, transparent 70%)',
bottom: '-10%', right: '-5%', pointerEvents: 'none',
}} />
<div className="animate-scale-in" style={{
width: '100%', maxWidth: 420, position: 'relative', zIndex: 1,
}}>
<div className="glass-card" style={{ padding: '2.5rem 2rem' }}>
{/* Logo */}
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
marginBottom: '2rem',
}}>
{schoolLogo ? (
<img
src={schoolLogo}
alt={schoolName}
style={{
maxWidth: 100, maxHeight: 100, objectFit: 'contain',
marginBottom: '1rem', borderRadius: 12,
}}
/>
) : (
<div style={{
width: 72, height: 72, borderRadius: 20,
background: 'var(--gradient-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: '1rem',
}}
className="animate-float"
>
<GraduationCap size={36} color="white" />
</div>
)}
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, textAlign: 'center' }}>
Portal do <span className="gradient-text">Aluno</span>
</h1>
<p style={{
color: 'var(--color-text-secondary)', fontSize: '0.875rem',
marginTop: '0.375rem', textAlign: 'center',
}}>
Acesse suas informações acadêmicas
</p>
</div>
{/* Error */}
{error && (
<div className="animate-fade-in" style={{
background: 'var(--bg-danger-alpha)', border: '1px solid var(--border-danger-alpha)',
borderRadius: 12, padding: '0.75rem 1rem', marginBottom: '1.25rem',
color: 'var(--color-danger)', fontSize: '0.8125rem', textAlign: 'center',
}}>
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{
display: 'block', fontSize: '0.8125rem', fontWeight: 500,
color: 'var(--color-text-secondary)', marginBottom: '0.375rem',
}}>
de Matrícula
</label>
<input
id="enrollment-input"
className="input-field"
type="text"
placeholder="Ex: MAT-202600001"
value={enrollment}
onChange={(e) => setEnrollment(e.target.value)}
autoComplete="username"
/>
</div>
<div>
<label style={{
display: 'block', fontSize: '0.8125rem', fontWeight: 500,
color: 'var(--color-text-secondary)', marginBottom: '0.375rem',
}}>
Senha
</label>
<div style={{ position: 'relative' }}>
<input
id="password-input"
className="input-field"
type={showPassword ? 'text' : 'password'}
placeholder="Digite sua senha"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
style={{ paddingRight: '3rem' }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', color: 'var(--color-text-secondary)',
cursor: 'pointer', padding: 4,
}}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<button
id="login-btn"
type="submit"
className="btn-primary"
disabled={loading}
style={{
width: '100%', padding: '0.9375rem', marginTop: '0.5rem',
fontSize: '0.9375rem',
}}
>
{loading ? (
<Loader2 size={20} style={{ animation: 'spin 1s linear infinite' }} />
) : (
'Entrar'
)}
</button>
</form>
<p style={{
textAlign: 'center', marginTop: '1.5rem',
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
}}>
Sua senha padrão são os 6 primeiros dígitos do seu CPF
</p>
</div>
<p style={{
textAlign: 'center', marginTop: '1.5rem',
fontSize: '0.6875rem', color: 'rgba(148,163,184,0.5)',
}}>
© {new Date().getFullYear()} EduManager Portal do Aluno
</p>
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,287 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import {
User, Mail, Phone, Calendar, MapPin, CreditCard,
Lock, Eye, EyeOff, Loader2, CheckCircle2, Shield
} from 'lucide-react';
export default function MeusDados() {
const { student, updatePassword } = useAuth();
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState('');
const [error, setError] = useState('');
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (newPassword.length < 4) {
setError('A nova senha deve ter pelo menos 4 caracteres');
return;
}
if (newPassword !== confirmPassword) {
setError('As senhas não coincidem');
return;
}
setLoading(true);
try {
await updatePassword(currentPassword, newPassword);
setSuccess('Senha alterada com sucesso!');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => {
setShowPasswordForm(false);
setSuccess('');
}, 2000);
} catch (err: any) {
setError(err.message || 'Erro ao alterar senha');
} finally {
setLoading(false);
}
};
if (!student) return null;
const formatDate = (d: string) => {
const date = new Date(d + 'T00:00:00');
return date.toLocaleDateString('pt-BR');
};
const InfoRow = ({ icon: Icon, label, value }: { icon: any; label: string; value?: string }) => (
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem',
padding: '0.875rem 0', borderBottom: '1px solid rgba(51,65,85,0.3)',
}}>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Icon size={18} color="var(--color-primary-light)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-secondary)', fontWeight: 500, textTransform: 'uppercase' }}>
{label}
</p>
<p style={{ fontSize: '0.875rem', fontWeight: 500, marginTop: 2, wordBreak: 'break-word' }}>
{value || '—'}
</p>
</div>
</div>
);
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Meus Dados</h1>
<p className="page-subtitle">Suas informações pessoais</p>
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))',
gap: '1.5rem',
}}>
{/* Personal Data */}
<div className="glass-card animate-fade-in" style={{ padding: '1.5rem' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem',
marginBottom: '1.5rem', paddingBottom: '1rem',
borderBottom: '1px solid var(--glass-border)',
}}>
{student.photo ? (
<img src={student.photo} alt={student.name} style={{
width: 64, height: 64, borderRadius: '50%', objectFit: 'cover',
border: '3px solid var(--color-primary)',
}} />
) : (
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'var(--gradient-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.5rem', fontWeight: 700,
}}>
{student.name.charAt(0)}
</div>
)}
<div>
<h3 style={{ fontWeight: 700, fontSize: '1.125rem' }}>{student.name}</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-accent)' }}>
{student.enrollmentNumber}
</p>
<span className={`badge ${student.status === 'active' ? 'badge-success' : 'badge-danger'}`} style={{ marginTop: 4 }}>
{student.status === 'active' ? 'Ativo' : student.status === 'inactive' ? 'Inativo' : 'Cancelado'}
</span>
</div>
</div>
<InfoRow icon={CreditCard} label="CPF" value={student.cpf} />
<InfoRow icon={CreditCard} label="RG" value={student.rg} />
<InfoRow icon={Calendar} label="Data de Nascimento" value={student.birthDate ? formatDate(student.birthDate) : undefined} />
<InfoRow icon={Phone} label="Telefone" value={student.phone} />
<InfoRow icon={Mail} label="Email" value={student.email} />
</div>
{/* Address + Guardian */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="glass-card animate-fade-in" style={{ padding: '1.5rem' }}>
<h3 style={{
fontWeight: 600, fontSize: '0.9375rem', marginBottom: '1rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
<MapPin size={18} color="var(--color-primary-light)" /> Endereço
</h3>
<div style={{ fontSize: '0.875rem', lineHeight: 1.8, color: 'var(--color-text-secondary)' }}>
{student.addressStreet ? (
<>
<p>{student.addressStreet}{student.addressNumber ? `, ${student.addressNumber}` : ''}</p>
<p>{student.addressNeighborhood}</p>
<p>{student.addressCity}{student.addressState ? ` - ${student.addressState}` : ''}</p>
{student.addressZip && <p>CEP: {student.addressZip}</p>}
</>
) : (
<p>Endereço não cadastrado</p>
)}
</div>
</div>
{/* Guardian */}
{(student.guardianName || student.guardianCpf) && (
<div className="glass-card animate-fade-in" style={{ padding: '1.5rem' }}>
<h3 style={{
fontWeight: 600, fontSize: '0.9375rem', marginBottom: '1rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
<Shield size={18} color="var(--color-primary-light)" /> Responsável
</h3>
<InfoRow icon={User} label="Nome" value={student.guardianName} />
<InfoRow icon={CreditCard} label="CPF" value={student.guardianCpf} />
{student.guardianPhone && <InfoRow icon={Phone} label="Telefone" value={student.guardianPhone} />}
{student.guardianEmail && <InfoRow icon={Mail} label="Email" value={student.guardianEmail} />}
</div>
)}
{/* Change Password */}
<div className="glass-card animate-fade-in" style={{ padding: '1.5rem' }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: showPasswordForm ? '1.25rem' : 0,
}}>
<h3 style={{
fontWeight: 600, fontSize: '0.9375rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
<Lock size={18} color="var(--color-primary-light)" /> Segurança
</h3>
{!showPasswordForm && (
<button
id="change-password-btn"
className="btn-secondary"
onClick={() => setShowPasswordForm(true)}
style={{ fontSize: '0.8125rem' }}
>
Alterar Senha
</button>
)}
</div>
{showPasswordForm && (
<form onSubmit={handlePasswordChange} style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
{error && (
<div className="animate-fade-in" style={{
background: 'var(--bg-danger-alpha)', border: '1px solid var(--border-danger-alpha)',
borderRadius: 10, padding: '0.625rem 0.875rem',
color: 'var(--color-danger)', fontSize: '0.8125rem',
}}>
{error}
</div>
)}
{success && (
<div className="animate-fade-in" style={{
background: 'var(--bg-success-alpha)', border: '1px solid var(--border-success-alpha)',
borderRadius: 10, padding: '0.625rem 0.875rem',
color: 'var(--color-success)', fontSize: '0.8125rem',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<CheckCircle2 size={16} /> {success}
</div>
)}
<div style={{ position: 'relative' }}>
<input
className="input-field"
type={showCurrent ? 'text' : 'password'}
placeholder="Senha atual"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
style={{ paddingRight: '2.5rem' }}
/>
<button type="button" onClick={() => setShowCurrent(!showCurrent)}
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', color: 'var(--color-text-secondary)', cursor: 'pointer',
}}
>
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<div style={{ position: 'relative' }}>
<input
className="input-field"
type={showNew ? 'text' : 'password'}
placeholder="Nova senha"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={{ paddingRight: '2.5rem' }}
/>
<button type="button" onClick={() => setShowNew(!showNew)}
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', color: 'var(--color-text-secondary)', cursor: 'pointer',
}}
>
{showNew ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<input
className="input-field"
type="password"
placeholder="Confirmar nova senha"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="submit" className="btn-primary" disabled={loading} style={{ flex: 1 }}>
{loading ? <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> : 'Salvar'}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => {
setShowPasswordForm(false);
setError('');
setSuccess('');
}}
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import type { Lesson } from '../types';
import { getLessonTimeStatus, parseLessonDateTime } from '../lib/lessonUtils';
import { useRealTimeDate } from '../hooks/useRealTimeDate';
export default function MinhasAulas() {
const { token } = useAuth();
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'scheduled' | 'history'>('scheduled');
// MUST be called logically at the top level
const now = useRealTimeDate(10000); // 10s updates
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/portal/aulas', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setLessons(data.lessons || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
try {
const ms = parseLessonDateTime(dateStr, '12:00', 12);
if (isNaN(ms)) return dateStr;
const date = new Date(ms);
return date.toLocaleDateString('pt-BR');
} catch {
return dateStr;
}
};
const formatTime = (timeStr?: string) => {
if (!timeStr || typeof timeStr !== 'string') return '';
return timeStr.substring(0, 5);
};
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 250, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 400, borderRadius: 16 }} />
</div>
);
}
const nowTime = now.getTime();
// Process and group lessons
const processedLessons = lessons.map(l => ({
...l,
...getLessonTimeStatus(l, now)
}));
const activeLessons = processedLessons.filter(l => !l.isCompleted && l.status !== 'cancelled').sort((a, b) => {
const timeA = parseLessonDateTime(a.date, a.startTime, 0);
const timeB = parseLessonDateTime(b.date, b.startTime, 0);
return timeA - timeB;
});
const historyLessons = processedLessons.filter(l => l.isCompleted || l.status === 'cancelled').sort((a, b) => {
const timeA = parseLessonDateTime(a.date, a.startTime, 0);
const timeB = parseLessonDateTime(b.date, b.startTime, 0);
return timeB - timeA; // History is descending
});
const displayLessons = activeTab === 'scheduled' ? activeLessons : historyLessons;
return (
<div className="page-container">
<style>{`
@keyframes blink-status {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
`}</style>
<div className="animate-fade-in" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h1 className="page-title">Cronograma de Aulas</h1>
<p className="page-subtitle">Acompanhe suas aulas e reposições agendadas</p>
</div>
<div className="glass-card" style={{ padding: '4px', display: 'flex', gap: '4px', borderRadius: 12 }}>
<button
onClick={() => setActiveTab('scheduled')}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s',
background: activeTab === 'scheduled' ? 'var(--color-primary)' : 'transparent',
color: activeTab === 'scheduled' ? 'white' : 'var(--color-text-secondary)',
}}
>
Aulas Agendadas ({activeLessons.length})
</button>
<button
onClick={() => setActiveTab('history')}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: '0.8125rem', fontWeight: 600, transition: 'all 0.2s',
background: activeTab === 'history' ? 'var(--color-primary)' : 'transparent',
color: activeTab === 'history' ? 'white' : 'var(--color-text-secondary)',
}}
>
Histórico ({historyLessons.length})
</button>
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} className="animate-fade-in stagger-children">
{displayLessons.length === 0 ? (
<div className="glass-card" style={{ padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
<CalendarIcon size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p>Nenhuma aula encontrada no cronograma.</p>
</div>
) : (
displayLessons.map((lesson, idx) => {
const isCancelled = lesson.status === 'cancelled';
const isRescheduled = lesson.status === 'rescheduled';
const isReposicao = lesson.type === 'reposicao';
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);
const isAbsoluteNext = idx === 0 && !isInProgress && !isCancelled && !isCompleted;
return (
<div
key={lesson.id}
className="glass-card"
style={{
padding: '1.5rem',
opacity: isCancelled ? 0.55 : 1,
borderLeft: isCancelled ? '4px solid var(--color-danger)'
: isInProgress && !isCancelled ? (lesson.type === 'extra' ? '4px solid #a855f7' : '4px solid var(--color-warning)')
: lesson.type === 'extra' ? '4px solid #a855f7'
: isAbsoluteNext ? '4px solid var(--color-primary)'
: isRescheduled ? '4px solid #8b5cf6'
: lesson.type === 'reposicao' ? '4px solid var(--color-success)'
: isCompleted ? '4px solid var(--color-success)'
: '4px solid var(--color-success)',
background: isInProgress && !isCancelled
? (lesson.type === 'extra' ? 'linear-gradient(90deg, rgba(168, 85, 247, 0.08) 0%, transparent 100%)' : 'linear-gradient(90deg, rgba(245, 158, 11, 0.08) 0%, transparent 100%)')
: isAbsoluteNext ? 'linear-gradient(90deg, rgba(99, 102, 241, 0.08) 0%, transparent 100%)'
: undefined,
boxShadow: isAbsoluteNext ? '0 4px 20px rgba(99, 102, 241, 0.12)' : undefined,
animation: isInProgress && !isCancelled ? 'pulse 3s infinite' : undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<div style={{ display: 'flex', gap: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<CalendarIcon size={16} />
<span style={{ textDecoration: isCancelled ? 'line-through' : 'none' }}>
🗓 {formatDate(lesson.date)}
</span>
</div>
{(lesson.startTime || lesson.endTime) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<Clock size={16} />
<span style={{ textDecoration: isCancelled ? 'line-through' : 'none' }}>
{formatTime(lesson.startTime)}{lesson.endTime ? ` às ${formatTime(lesson.endTime)}` : ''}
</span>
</div>
)}
</div>
{(isReposicao || lesson.type === 'extra') && (
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{lesson.type === 'extra' && (
<span style={{
background: '#9333ea', color: 'white',
padding: '2px 8px', borderRadius: 4, fontSize: '0.65rem', fontWeight: 600,
border: '1px solid rgba(147, 51, 234, 0.3)'
}}>
AULA EXTRA
</span>
)}
{isReposicao && (
<span style={{
background: 'var(--bg-success-alpha)', color: 'var(--color-success)',
padding: '2px 8px', borderRadius: 4, fontSize: '0.65rem', fontWeight: 600,
border: '1px solid var(--color-success-alpha)'
}}>
AULA DE REPOSIÇÃO
</span>
)}
{lesson.originalLessonId && (
<span style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', alignSelf: 'center' }}>
Ref. aula original
</span>
)}
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end', gap: '0.5rem' }}>
{isCancelled && (
<span style={{
background: 'var(--color-danger)', color: 'white',
padding: '4px 10px', borderRadius: 6, fontSize: '0.75rem', fontWeight: 600,
}}>
CANCELADA{lesson.cancelReason ? ` - ${lesson.cancelReason}` : ''}
</span>
)}
{isRescheduled && !isInProgress && (
<span style={{
background: 'var(--bg-warning-alpha)', color: 'var(--color-warning)',
padding: '4px 10px', borderRadius: 6, fontSize: '0.75rem', fontWeight: 600,
border: '1px solid var(--bg-warning-alpha)',
display: 'flex', alignItems: 'center', gap: 4
}}>
REAGENDADA
</span>
)}
{isInProgress && !isCancelled && (
<span className="animate-pulse" style={{
background: 'var(--color-warning)', color: 'white',
padding: '4px 10px', borderRadius: 6, fontSize: '0.75rem', fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 4,
boxShadow: '0 0 15px var(--bg-warning-alpha)'
}}>
<Clock size={12} /> EM ANDAMENTO
</span>
)}
{isCompleted && !isCancelled && !isInProgress && (
<span style={{
background: 'var(--bg-success-alpha)', color: 'var(--color-success)',
padding: '4px 10px', borderRadius: 6, fontSize: '0.75rem', fontWeight: 600,
border: '1px solid var(--bg-success-alpha)',
display: 'flex', alignItems: 'center', gap: 4
}}>
CONCLUÍDA
</span>
)}
{!isCancelled && !isCompleted && !isRescheduled && !isInProgress && (
<span style={{
background: 'var(--bg-primary-alpha)', color: 'var(--color-primary)',
padding: '4px 10px', borderRadius: 6, fontSize: '0.75rem', fontWeight: 600,
border: '1px solid var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', gap: 4
}}>
AGENDADA
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}

205
portal/src/pages/Notas.tsx Normal file
View File

@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { BookOpen } from 'lucide-react';
import type { Grade, Subject } from '../types';
interface GradeWithSubject extends Grade {
subjectName: string;
}
export default function Notas() {
const { token } = useAuth();
const [grades, setGrades] = useState<GradeWithSubject[]>([]);
const [allSubjects, setAllSubjects] = useState<Subject[]>([]);
const [periods, setPeriods] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/portal/notas', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setGrades(data.grades || []);
setPeriods(data.periods || []);
setAllSubjects(data.allSubjects || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchData();
}, [token]);
if (loading) {
return (
<div className="page-container">
<div className="skeleton" style={{ width: 200, height: 32, marginBottom: 24 }} />
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 16 }} />
</div>
);
}
// Use subjects from course instead of deriving from grades
const displaySubjects = allSubjects.length > 0
? allSubjects
: [...new Set(grades.map(g => g.subjectId))].map(id => ({
id,
name: grades.find(g => g.subjectId === id)?.subjectName || id
}));
// Logic for General Average: Only show if EVERY subject has a grade > 0
const isAllGraded = displaySubjects.length > 0 && displaySubjects.every(s => {
const subjectId = typeof s === 'string' ? s : s.id;
const subjectGrades = grades.filter(g => g.subjectId === subjectId);
return subjectGrades.length > 0 && subjectGrades.every(g => g.value > 0);
});
const totalAvg = isAllGraded
? grades.reduce((s, g) => s + g.value, 0) / (displaySubjects.length || 1)
: 0;
const getGradeColor = (value: number) => {
if (value >= 7) return 'var(--color-success)';
if (value >= 5) return 'var(--color-warning)';
return 'var(--color-danger)';
};
return (
<div className="page-container">
<div className="animate-fade-in" style={{
marginBottom: '2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '1.5rem'
}}>
<div>
<h1 className="page-title">Notas & Boletim</h1>
<p className="page-subtitle">Acompanhe seu desempenho acadêmico</p>
</div>
<div className="glass-card" style={{
padding: '1rem 2rem',
textAlign: 'center',
background: 'var(--bg-primary-alpha)',
border: '1px solid var(--color-primary-alpha)',
borderRadius: '24px',
minWidth: 200
}}>
<p style={{
fontSize: '0.75rem',
fontWeight: 700,
color: 'var(--color-primary)',
letterSpacing: '0.1em',
marginBottom: '0.25rem',
textTransform: 'uppercase'
}}>Média Geral</p>
<p style={{
fontSize: isAllGraded ? '3rem' : '1.25rem',
fontWeight: 800,
color: 'var(--color-text-primary)',
lineHeight: 1.2,
marginTop: 0,
marginBottom: 0,
whiteSpace: 'nowrap'
}}>
{isAllGraded ? totalAvg.toFixed(1) : 'Aguardando notas...'}
</p>
</div>
</div>
{displaySubjects.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '3rem', textAlign: 'center', color: 'var(--color-text-secondary)',
}}>
<BookOpen size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p style={{ fontSize: '0.9375rem' }}>Nenhuma matéria cadastrada no curso</p>
</div>
) : (
<div className="glass-card animate-fade-in" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>Disciplina</th>
<th style={{ textAlign: 'center' }}>Nota / Média</th>
</tr>
</thead>
<tbody>
{displaySubjects.map((s, idx) => {
const subjectId = typeof s === 'string' ? s : s.id;
const subjectName = typeof s === 'string' ? s : s.name;
const subjectGrades = grades.filter(g => g.subjectId === subjectId);
const avg = subjectGrades.length > 0
? subjectGrades.reduce((sum, g) => sum + g.value, 0) / subjectGrades.length
: 0;
return (
<tr key={subjectId} style={{
animation: `fadeIn 0.3s ease-out ${idx * 0.05}s forwards`,
opacity: 0,
}}>
<td style={{ fontWeight: 500 }}>
{subjectName}
</td>
<td style={{ textAlign: 'center' }}>
{subjectGrades.length > 0 && avg > 0 ? (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 48, height: 32, borderRadius: 8, padding: '0 8px',
background: avg >= 7 ? 'var(--bg-success-alpha)' : avg >= 5 ? 'var(--bg-warning-alpha)' : 'var(--bg-danger-alpha)',
fontWeight: 700, fontSize: '0.925rem',
color: getGradeColor(avg),
}}>
{avg.toFixed(1)}
</span>
) : (
<span style={{
padding: '4px 10px',
background: 'var(--color-surface-light)',
borderRadius: 6,
color: 'var(--color-text-secondary)',
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.025em'
}}>
Aguardando
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Legend */}
<div className="animate-fade-in" style={{
display: 'flex', gap: '1.5rem', marginTop: '1rem',
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
flexWrap: 'wrap',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-success)' }} />
Aprovado ( 7.0)
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-warning)' }} />
Recuperação (5.0 - 6.9)
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-danger)' }} />
Reprovado (&lt; 5.0)
</span>
</div>
</div>
);
}

425
portal/src/styles/index.css Normal file
View File

@ -0,0 +1,425 @@
@import "tailwindcss";
/* ==========================================
Portal do Aluno Global Styles
========================================== */
:root {
/* Default Dark Mode Variables */
--color-primary: #6366f1;
--color-primary-light: #818cf8;
--color-primary-dark: #4f46e5;
--color-accent: #06b6d4;
--color-accent-light: #22d3ee;
--color-surface: #0f172a;
--color-surface-light: #1e293b;
--color-surface-lighter: #334155;
--color-text: #f8fafc;
--color-text-secondary: #94a3b8;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-info: #3b82f6;
--color-border: #334155;
/* Alphas and Complex colors */
--bg-info-alpha: rgba(59, 130, 246, 0.15);
--bg-primary-alpha: rgba(99, 102, 241, 0.15);
--bg-accent-alpha: rgba(6, 182, 212, 0.15);
--bg-danger-alpha: rgba(239, 68, 68, 0.15);
--bg-success-alpha: rgba(16, 185, 129, 0.15);
--bg-warning-alpha: rgba(245, 158, 11, 0.15);
--border-danger-alpha: rgba(239, 68, 68, 0.3);
--border-success-alpha: rgba(16, 185, 129, 0.3);
--overlay-bg: rgba(0, 0, 0, 0.4);
--header-bg: rgba(15, 23, 42, 0.9);
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #06b6d4 100%);
--gradient-dark: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
--gradient-sidebar: linear-gradient(180deg, #0c1222 0%, #131b2e 50%, #0f172a 100%);
--gradient-login: linear-gradient(135deg, #0c1222 0%, #1a1040 50%, #0f172a 100%);
--gradient-warning: linear-gradient(135deg, rgba(245,158,11,0.2) 0%, rgba(234,179,8,0.1) 100%);
--glass-bg: rgba(30, 41, 59, 0.7);
--glass-border: rgba(148, 163, 184, 0.1);
--table-hover: rgba(99, 102, 241, 0.05);
--table-border: rgba(51, 65, 85, 0.5);
}
:root[data-theme="light"] {
/* Light Mode Equivalents */
--color-primary: #4f46e5;
--color-primary-light: #6366f1;
--color-primary-dark: #3730a3;
--color-accent: #0891b2;
--color-accent-light: #06b6d4;
--color-surface: #f8fafc;
--color-surface-light: #ffffff;
--color-surface-lighter: #f1f5f9;
--color-text: #0f172a;
--color-text-secondary: #475569;
--color-success: #059669;
--color-warning: #b45309;
--color-danger: #dc2626;
--color-border: #cbd5e1;
/* Alphas and Complex colors */
--bg-primary-alpha: rgba(79, 70, 229, 0.1);
--bg-accent-alpha: rgba(8, 145, 178, 0.1);
--bg-danger-alpha: rgba(220, 38, 38, 0.1);
--bg-success-alpha: rgba(5, 150, 105, 0.1);
--bg-warning-alpha: rgba(180, 83, 9, 0.1);
--border-danger-alpha: rgba(220, 38, 38, 0.3);
--border-success-alpha: rgba(5, 150, 105, 0.3);
--overlay-bg: rgba(15, 23, 42, 0.5);
--header-bg: rgba(255, 255, 255, 0.9);
--gradient-primary: linear-gradient(135deg, #4f46e5 0%, #0891b2 100%);
--gradient-dark: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
--gradient-sidebar: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
--gradient-login: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 50%, #f8fafc 100%);
--gradient-warning: linear-gradient(135deg, rgba(245,158,11,0.1) 0%, rgba(234,179,8,0.05) 100%);
--glass-bg: rgba(255, 255, 255, 0.75);
--glass-border: rgba(15, 23, 42, 0.08);
--table-hover: rgba(79, 70, 229, 0.05);
--table-border: rgba(203, 213, 225, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--color-surface);
color: var(--color-text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-lighter);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
}
/* ===== Animations ===== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.15); }
50% { box-shadow: 0 0 30px rgba(99, 102, 241, 0.3); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
.animate-slide-left {
animation: slideInLeft 0.4s ease-out forwards;
}
.animate-slide-right {
animation: slideInRight 0.4s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.3s ease-out forwards;
}
.animate-pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.98); }
}
@keyframes pulse-glow {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
70% { transform: scale(1.05); box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* ===== Glass Card ===== */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
border-radius: 16px;
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
}
/* ===== Gradient Text ===== */
.gradient-text {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Buttons ===== */
.btn-primary {
background: var(--gradient-primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.35);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--color-surface-lighter);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: 0.625rem 1.25rem;
border-radius: 12px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-secondary:hover {
background: var(--color-primary);
border-color: var(--color-primary);
transform: translateY(-1px);
}
/* ===== Input ===== */
.input-field {
width: 100%;
padding: 0.875rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-text);
font-size: 0.9375rem;
font-family: 'Inter', sans-serif;
transition: all 0.3s ease;
outline: none;
}
.input-field::placeholder {
color: var(--color-text-secondary);
}
.input-field:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
/* ===== Badges ===== */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.badge-success {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.badge-warning {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.badge-danger {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.badge-info {
background: rgba(6, 182, 212, 0.15);
color: #22d3ee;
}
/* ===== Table ===== */
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.data-table thead th {
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.875rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 10;
}
.data-table tbody td {
padding: 0.875rem 1rem;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
font-size: 0.875rem;
vertical-align: middle;
}
.data-table tbody tr {
transition: background 0.2s ease;
}
.data-table tbody tr:hover {
background: rgba(99, 102, 241, 0.05);
}
/* ===== Page Layout ===== */
.page-container {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.page-subtitle {
color: var(--color-text-secondary);
font-size: 0.9375rem;
}
/* ===== Stagger Animation Children ===== */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.5s ease-out forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.24s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.32s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.40s; }
.stagger-children > *:nth-child(7) { animation-delay: 0.48s; }
.stagger-children > *:nth-child(8) { animation-delay: 0.56s; }
/* ===== Loading Skeleton ===== */
.skeleton {
background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.page-container {
padding: 1rem;
}
.page-title {
font-size: 1.375rem;
}
.data-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}

200
portal/src/types.ts Normal file
View File

@ -0,0 +1,200 @@
// ==========================================
// Portal do Aluno — Shared TypeScript Types
// ==========================================
export interface Student {
id: string;
name: string;
email: string;
phone: string;
birthDate: string;
cpf: string;
rg?: string;
classId: string;
status: 'active' | 'inactive' | 'cancelled';
registrationDate: string;
photo?: string;
addressZip?: string;
addressStreet?: string;
addressNumber?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
enrollmentNumber?: string;
portalPassword?: string;
discount?: number;
guardianName?: string;
guardianCpf?: string;
guardianPhone?: string;
guardianEmail?: string;
}
export interface Payment {
id: string;
studentId: string;
amount: number;
discount?: number;
dueDate: string;
status: 'pending' | 'paid' | 'overdue';
paidDate?: string;
type: 'monthly' | 'registration' | 'other';
installmentNumber?: number;
totalInstallments?: number;
description?: string;
asaasPaymentId?: string;
asaasPaymentUrl?: string;
transactionReceiptUrl?: string;
}
export interface Grade {
id: string;
studentId: string;
subjectId: string;
value: number;
period: string;
}
export interface Attendance {
id: string;
studentId: string;
classId: string;
date: string; // ISO String (UTC)
photo?: string;
verified: boolean;
type?: 'presence' | 'absence';
justification?: string; // string (upload em base64 ou texto do motivo)
justificationAccepted?: boolean;
}
export interface Class {
id: string;
name: string;
courseId: string;
teacher: string;
schedule: string;
}
export interface Course {
id: string;
name: string;
duration: string;
monthlyFee: number;
}
export interface Contract {
id: string;
studentId: string;
title: string;
content: string;
createdAt: string;
}
export interface Certificate {
id: string;
studentId: string;
description?: string;
issueDate: string;
}
export interface Subject {
id: string;
name: string;
classId?: string;
}
export interface SchoolProfile {
id: string;
name: string;
address: string;
city: string;
state: string;
cnpj: string;
phone: string;
email: string;
}
export interface Boleto {
id: string;
aluno_id: string;
asaas_customer_id?: string;
asaas_payment_id?: string;
asaas_installment_id?: string;
valor: number;
vencimento: string;
data_pagamento?: string;
link_boleto?: string;
link_carne?: string;
link_recibo?: string;
transaction_receipt_url?: string;
status: string;
created_at: string;
}
export interface AuthUser {
studentId: string;
enrollmentNumber: string;
name: string;
}
// ==========================================
// Novas Interfaces — SchoolData (EduManager)
// ==========================================
export interface Lesson {
id: string;
classId: string;
date: string; // ISO Date YYYY-MM-DD
startTime?: string; // HH:mm
endTime?: string; // HH:mm
status: 'scheduled' | 'cancelled' | 'completed' | 'rescheduled';
type: 'regular' | 'reposicao' | 'extra';
cancelReason?: string;
originalLessonId?: string;
}
export interface Notification {
id: string;
studentId: string;
title: string;
message: string;
read: boolean;
createdAt: string; // ISO string
}
// ==========================================
// Avaliações (Exams)
// ==========================================
export interface ExamQuestion {
id: string;
text: string;
imageUrl?: string;
options: string[];
correctOptionIndex: number;
}
export interface Exam {
id: string;
title: string;
description?: string;
classId: string;
courseId?: string;
durationMinutes: number;
questions: ExamQuestion[];
status: 'draft' | 'published' | 'archived';
createdAt: string;
dueDate?: string;
}
export interface ExamSubmission {
id?: string;
aluno_id: string;
exam_id: string;
total_questions: number;
correct_count: number;
wrong_count: number;
percentage: number;
final_score: number;
answers_json: Record<string, number>;
created_at?: string;
}

1
portal/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

23
portal/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src"]
}

19
portal/vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});

440
schema.sql Normal file
View File

@ -0,0 +1,440 @@
-- ============================================================
-- SCHEMA NORMALIZADO PARA O EDUMANAGER SELF-HOSTED
-- PostgreSQL 15
-- Baseado nas interfaces TypeScript do types.ts
-- ============================================================
-- Extensão para gerar UUIDs
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================
-- 1. PERFIL DA ESCOLA (Configurações Gerais)
-- ============================================================
CREATE TABLE IF NOT EXISTS configuracoes (
id TEXT PRIMARY KEY DEFAULT 'main-school',
nome TEXT NOT NULL DEFAULT 'EduManager School',
endereco TEXT DEFAULT '',
cidade TEXT DEFAULT '',
estado TEXT DEFAULT '',
cep TEXT DEFAULT '',
cnpj TEXT DEFAULT '',
telefone TEXT DEFAULT '',
email TEXT DEFAULT '',
tipo TEXT DEFAULT 'matriz' CHECK (tipo IN ('matriz', 'filial')),
logo TEXT DEFAULT '',
-- Configurações do Evolution API (WhatsApp)
evolution_api_url TEXT,
evolution_instance_name TEXT,
evolution_api_key TEXT,
-- Templates de mensagens (JSON)
message_templates JSONB DEFAULT '{}',
-- Timestamp
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 2. USUÁRIOS DO PAINEL ADMINISTRATIVO
-- ============================================================
CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
username TEXT NOT NULL UNIQUE,
display_name TEXT,
photo_url TEXT,
password TEXT NOT NULL,
cpf TEXT DEFAULT '',
role TEXT DEFAULT 'admin' CHECK (role IN ('admin', 'user')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Admin padrão
INSERT INTO usuarios (id, username, display_name, password, cpf, role)
VALUES ('default-admin', 'admin', 'Administrador', 'admin', '000.000.000-00', 'admin')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 3. CURSOS
-- ============================================================
CREATE TABLE IF NOT EXISTS cursos (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
duracao TEXT DEFAULT '',
duracao_meses INTEGER DEFAULT 0,
taxa_matricula NUMERIC(10,2) DEFAULT 0,
mensalidade NUMERIC(10,2) DEFAULT 0,
descricao TEXT DEFAULT '',
multa_percentual NUMERIC(5,2) DEFAULT 0,
juros_percentual NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 4. TURMAS
-- ============================================================
CREATE TABLE IF NOT EXISTS turmas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
curso_id TEXT REFERENCES cursos(id) ON DELETE SET NULL,
professor TEXT DEFAULT '',
horario TEXT DEFAULT '',
dia_semana TEXT,
max_alunos INTEGER DEFAULT 30,
data_inicio DATE,
data_fim DATE,
horario_inicio_padrao TEXT,
horario_fim_padrao TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 5. ALUNOS
-- ============================================================
CREATE TABLE IF NOT EXISTS alunos (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
email TEXT DEFAULT '',
telefone TEXT DEFAULT '',
data_nascimento DATE,
cpf TEXT DEFAULT '',
rg TEXT,
rg_data_emissao DATE,
nome_responsavel TEXT,
telefone_responsavel TEXT,
cpf_responsavel TEXT,
data_nascimento_responsavel DATE,
turma_id TEXT REFERENCES turmas(id) ON DELETE SET NULL,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'cancelled')),
motivo_cancelamento TEXT,
data_matricula DATE DEFAULT CURRENT_DATE,
foto_url TEXT,
face_descriptor JSONB,
-- Endereço
cep TEXT DEFAULT '',
rua TEXT DEFAULT '',
numero TEXT DEFAULT '',
bairro TEXT DEFAULT '',
cidade TEXT DEFAULT '',
estado TEXT DEFAULT '',
-- Financeiro
desconto NUMERIC(10,2) DEFAULT 0,
tem_responsavel BOOLEAN DEFAULT FALSE,
modelo_contrato_id TEXT,
-- Portal do Aluno
numero_matricula TEXT UNIQUE,
senha_portal TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 6. AULAS / CRONOGRAMA
-- ============================================================
CREATE TABLE IF NOT EXISTS aulas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
turma_id TEXT NOT NULL REFERENCES turmas(id) ON DELETE CASCADE,
data DATE NOT NULL,
horario_inicio TEXT,
horario_fim TEXT,
status TEXT DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'completed', 'rescheduled')),
tipo TEXT DEFAULT 'regular' CHECK (tipo IN ('regular', 'reposicao', 'extra')),
motivo_cancelamento TEXT,
aula_original_id TEXT REFERENCES aulas(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 7. FREQUÊNCIAS
-- ============================================================
CREATE TABLE IF NOT EXISTS frequencias (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
turma_id TEXT NOT NULL REFERENCES turmas(id) ON DELETE CASCADE,
aula_id TEXT REFERENCES aulas(id) ON DELETE SET NULL,
data TIMESTAMPTZ NOT NULL,
foto TEXT,
verificado BOOLEAN DEFAULT FALSE,
tipo TEXT DEFAULT 'presence' CHECK (tipo IN ('presence', 'absence')),
justificativa TEXT,
justificativa_aceita BOOLEAN,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 8. DISCIPLINAS
-- ============================================================
CREATE TABLE IF NOT EXISTS disciplinas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
turma_id TEXT REFERENCES turmas(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 9. PERÍODOS
-- ============================================================
CREATE TABLE IF NOT EXISTS periodos (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 10. NOTAS
-- ============================================================
CREATE TABLE IF NOT EXISTS notas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
disciplina_id TEXT NOT NULL REFERENCES disciplinas(id) ON DELETE CASCADE,
valor NUMERIC(5,2) DEFAULT 0,
periodo TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(aluno_id, disciplina_id, periodo)
);
-- ============================================================
-- 11. PAGAMENTOS / FINANCEIRO
-- ============================================================
CREATE TABLE IF NOT EXISTS pagamentos (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
contrato_id TEXT,
valor NUMERIC(10,2) NOT NULL,
desconto NUMERIC(10,2) DEFAULT 0,
tipo_desconto TEXT CHECK (tipo_desconto IN ('fixed', 'percentage')),
multa NUMERIC(10,2) DEFAULT 0,
juros NUMERIC(10,2) DEFAULT 0,
vencimento DATE NOT NULL,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'overdue', 'received', 'confirmed')),
data_pagamento DATE,
tipo TEXT DEFAULT 'monthly' CHECK (tipo IN ('monthly', 'registration', 'other')),
numero_parcela INTEGER,
total_parcelas INTEGER,
descricao TEXT,
asaas_payment_id TEXT,
asaas_payment_url TEXT,
installment_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 12. CONTRATOS
-- ============================================================
CREATE TABLE IF NOT EXISTS contratos (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
titulo TEXT NOT NULL,
conteudo TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 13. MODELOS DE CONTRATO
-- ============================================================
CREATE TABLE IF NOT EXISTS modelos_contrato (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
conteudo TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Modelo padrão
INSERT INTO modelos_contrato (id, nome, conteudo)
VALUES ('default-template', 'Contrato Padrão', 'CONTRATO DE PRESTAÇÃO DE SERVIÇOS EDUCACIONAIS
Pelo presente instrumento particular, de um lado {{escola}} (CNPJ: {{cnpj_escola}}), e de outro lado o(a) aluno(a) {{aluno}}, celebram o presente contrato:
1. DO OBJETO: Prestação de serviços educacionais no curso de {{curso}}.
2. DA DURAÇÃO: O curso terá a duração estimada de {{duracao}}.
3. DO INVESTIMENTO: O CONTRATANTE pagará o valor mensal de R$ {{mensalidade}}.
4. DAS OBRIGAÇÕES: A CONTRATADA disponibilizará material e instrutores qualificados.
Data: {{data}}
___________________________________________
Assinatura do Aluno / Responsável')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 14. NOTIFICAÇÕES
-- ============================================================
CREATE TABLE IF NOT EXISTS notificacoes (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL,
titulo TEXT NOT NULL,
mensagem TEXT NOT NULL,
lida BOOLEAN DEFAULT FALSE,
anexo TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 15. CERTIFICADOS
-- ============================================================
CREATE TABLE IF NOT EXISTS certificados (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
descricao TEXT,
imagem_frente TEXT NOT NULL,
imagem_verso TEXT,
data_emissao DATE NOT NULL,
overlays_frente JSONB DEFAULT '[]',
overlays_verso JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 16. MODELOS DE CERTIFICADO
-- ============================================================
CREATE TABLE IF NOT EXISTS modelos_certificado (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
imagem_frente TEXT NOT NULL,
imagem_verso TEXT,
overlays_frente JSONB DEFAULT '[]',
overlays_verso JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 17. APOSTILAS
-- ============================================================
CREATE TABLE IF NOT EXISTS apostilas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
preco NUMERIC(10,2) DEFAULT 0,
descricao TEXT,
multa_percentual NUMERIC(5,2) DEFAULT 0,
juros_percentual NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 18. ENTREGAS DE APOSTILAS
-- ============================================================
CREATE TABLE IF NOT EXISTS entregas_apostilas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
apostila_id TEXT NOT NULL REFERENCES apostilas(id) ON DELETE CASCADE,
status_entrega TEXT DEFAULT 'pending' CHECK (status_entrega IN ('pending', 'delivered')),
status_pagamento TEXT DEFAULT 'pending' CHECK (status_pagamento IN ('pending', 'paid')),
data_entrega DATE,
data_pagamento DATE,
asaas_payment_id TEXT,
asaas_payment_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 19. CATEGORIAS DE FUNCIONÁRIOS
-- ============================================================
CREATE TABLE IF NOT EXISTS categorias_funcionarios (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 20. FUNCIONÁRIOS
-- ============================================================
CREATE TABLE IF NOT EXISTS funcionarios (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
nome TEXT NOT NULL,
cpf TEXT DEFAULT '',
telefone TEXT DEFAULT '',
email TEXT DEFAULT '',
data_admissao DATE,
categoria_id TEXT REFERENCES categorias_funcionarios(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 21. PROVAS / AVALIAÇÕES
-- ============================================================
CREATE TABLE IF NOT EXISTS provas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
turma_id TEXT NOT NULL REFERENCES turmas(id) ON DELETE CASCADE,
disciplina_id TEXT REFERENCES disciplinas(id) ON DELETE SET NULL,
periodo_id TEXT,
titulo TEXT NOT NULL,
duracao_minutos INTEGER DEFAULT 60,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 22. QUESTÕES DAS PROVAS
-- ============================================================
CREATE TABLE IF NOT EXISTS questoes_provas (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
prova_id TEXT NOT NULL REFERENCES provas(id) ON DELETE CASCADE,
texto TEXT NOT NULL,
imagem_url TEXT,
opcoes JSONB NOT NULL DEFAULT '[]',
indice_correto INTEGER NOT NULL DEFAULT 0,
ordem INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 23. SUBMISSÕES DE PROVAS (já existe como alunos_cobrancas no Supabase)
-- ============================================================
CREATE TABLE IF NOT EXISTS provas_submissoes (
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::TEXT,
aluno_id TEXT NOT NULL REFERENCES alunos(id) ON DELETE CASCADE,
prova_id TEXT NOT NULL REFERENCES provas(id) ON DELETE CASCADE,
total_questoes INTEGER DEFAULT 0,
acertos INTEGER DEFAULT 0,
erros INTEGER DEFAULT 0,
percentual NUMERIC(5,2) DEFAULT 0,
nota_final NUMERIC(5,2) DEFAULT 0,
respostas JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(aluno_id, prova_id)
);
-- ============================================================
-- 24. COBRANÇAS ASAAS (tabela que já existia separada)
-- ============================================================
CREATE TABLE IF NOT EXISTS alunos_cobrancas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
aluno_id TEXT NOT NULL,
asaas_customer_id TEXT,
asaas_payment_id TEXT,
asaas_installment_id TEXT,
installment TEXT,
valor NUMERIC(10,2),
vencimento DATE,
status TEXT DEFAULT 'PENDENTE',
data_pagamento DATE,
link_boleto TEXT,
link_carne TEXT,
transaction_receipt_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 25. TABELA LEGADA (school_data JSON blob - ponte de migração)
-- Mantida para compatibilidade durante a transição
-- ============================================================
CREATE TABLE IF NOT EXISTS school_data (
id INTEGER PRIMARY KEY DEFAULT 1,
data JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- ÍNDICES PARA PERFORMANCE
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_alunos_turma ON alunos(turma_id);
CREATE INDEX IF NOT EXISTS idx_alunos_status ON alunos(status);
CREATE INDEX IF NOT EXISTS idx_alunos_matricula ON alunos(numero_matricula);
CREATE INDEX IF NOT EXISTS idx_frequencias_aluno ON frequencias(aluno_id);
CREATE INDEX IF NOT EXISTS idx_frequencias_turma ON frequencias(turma_id);
CREATE INDEX IF NOT EXISTS idx_frequencias_data ON frequencias(data);
CREATE INDEX IF NOT EXISTS idx_notas_aluno ON notas(aluno_id);
CREATE INDEX IF NOT EXISTS idx_pagamentos_aluno ON pagamentos(aluno_id);
CREATE INDEX IF NOT EXISTS idx_pagamentos_status ON pagamentos(status);
CREATE INDEX IF NOT EXISTS idx_aulas_turma ON aulas(turma_id);
CREATE INDEX IF NOT EXISTS idx_aulas_data ON aulas(data);
CREATE INDEX IF NOT EXISTS idx_notificacoes_aluno ON notificacoes(aluno_id);
CREATE INDEX IF NOT EXISTS idx_cobrancas_aluno ON alunos_cobrancas(aluno_id);
CREATE INDEX IF NOT EXISTS idx_cobrancas_asaas ON alunos_cobrancas(asaas_payment_id);
CREATE INDEX IF NOT EXISTS idx_cobrancas_installment ON alunos_cobrancas(asaas_installment_id);