Initial Monorepo Push: EduManager + Portal do Aluno (Self-Hosted)
This commit is contained in:
commit
6b2522f038
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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`
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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 LÊ.
|
||||
* - 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);
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
@ -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`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
PORT=3001
|
||||
VITE_SUPABASE_URL=https://xxxx.supabase.co
|
||||
VITE_SUPABASE_KEY=eyJhb...
|
||||
JWT_SECRET=uma-chave-secreta-forte-aqui
|
||||
|
|
@ -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*
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.**
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
|
|
@ -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'}`);
|
||||
});
|
||||
|
|
@ -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'}`);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
}}>
|
||||
Nº 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (< 5.0)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue