commit 6b2522f038f6a43de22600915687ed2b150e265e Author: Sidney Date: Sun Apr 19 15:42:28 2026 -0300 Initial Monorepo Push: EduManager + Portal do Aluno (Self-Hosted) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..231907f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/manager/.dockerignore b/manager/.dockerignore new file mode 100644 index 0000000..53824f5 --- /dev/null +++ b/manager/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.gitignore +*.md +.env +.env.* +!.env.example diff --git a/manager/.env.example b/manager/.env.example new file mode 100644 index 0000000..4be0b2b --- /dev/null +++ b/manager/.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 diff --git a/manager/.gitignore b/manager/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/manager/.gitignore @@ -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? diff --git a/manager/DialogContext.tsx b/manager/DialogContext.tsx new file mode 100644 index 0000000..f90800f --- /dev/null +++ b/manager/DialogContext.tsx @@ -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(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(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 ; + case 'error': return ; + case 'warning': return ; + case 'alert': return ; + default: return ; + } + }; + + 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 ( + + {children} + {isOpen && options && ( +
+
+
+
+
+ {getIcon(options.type)} +
+

{options.title}

+

{options.message}

+
+ {options.cancelLabel && ( + + )} + +
+
+
+
+ )} +
+ ); +}; diff --git a/manager/Dockerfile b/manager/Dockerfile new file mode 100644 index 0000000..3fade85 --- /dev/null +++ b/manager/Dockerfile @@ -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"] diff --git a/manager/README.md b/manager/README.md new file mode 100644 index 0000000..17a3ae6 --- /dev/null +++ b/manager/README.md @@ -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` diff --git a/manager/components/AIHelper.tsx b/manager/components/AIHelper.tsx new file mode 100644 index 0000000..f0818b5 --- /dev/null +++ b/manager/components/AIHelper.tsx @@ -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 = ({ 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 ( +
+
+

+ Assistente IA +

+

Insights inteligentes para otimizar sua gestão.

+
+ +
+
+ {messages.map((msg, i) => ( +
+
+
+ {msg.role === 'user' ? <> Você : <> EduManager AI} +
+
+ {msg.content} +
+
+
+ ))} + {isLoading && ( +
+
+ + Analisando dados... +
+
+ )} +
+ +
+
+ {quickActions.map((action, i) => ( + + ))} +
+
+ setPrompt(e.target.value)} + /> + +
+
+
+
+ ); +}; + +export default AIHelper; \ No newline at end of file diff --git a/manager/components/AdminNotifications.tsx b/manager/components/AdminNotifications.tsx new file mode 100644 index 0000000..9d2f3c4 --- /dev/null +++ b/manager/components/AdminNotifications.tsx @@ -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) => void; + setView: (view: View) => void; + onNavigateToStudent?: (studentId: string) => void; +} + +const AdminNotifications: React.FC = ({ data, updateData, setView, onNavigateToStudent }) => { + const [isOpen, setIsOpen] = useState(false); + const [viewingAttachment, setViewingAttachment] = useState(null); + const [notifWithAttachment, setNotifWithAttachment] = useState(null); + const prevCountRef = useRef(0); + const audioRef = useRef(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 ( +
+ + + {isOpen && ( +
+
+
+

Avaliações Pendentes + {unreadCount > 0 && {unreadCount}} +

+
+
+ + +
+
+ +
+ {adminNotifs.length === 0 ? ( +
+ +

Nenhuma notificação

+

Sua caixa de entrada está limpa.

+
+ ) : ( +
+ {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 ( +
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 &&
} +
+

+ {notif.title} +

+ + {new Date(notif.createdAt).toLocaleDateString('pt-BR')} + +
+

+ {displayMessage} +

+ {(!notif.read) && ( +
+ {isJustificativa && ( + + )} + {isJustificativa && ( + + )} + {finalAttachment && ( + + )} + +
+ )} +
+ ); + })} +
+ )} +
+
+ )} + + {viewingAttachment && ( +
+
+
+

+ Visualização do Documento +

+
+ + +
+
+
+ {viewingAttachment.startsWith('data:application/pdf') || viewingAttachment.includes('.pdf') ? ( +