From be3e39e62d494e2371e89f9df813bef0229ae7de Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Fri, 24 Jul 2026 18:58:48 -0300 Subject: [PATCH] feat: suporte modal de tickets, aba de mensagens no admin, refatoracoes ui devflix e correcoes uppercase do brandName --- prisma/schema.prisma | 13 ++ server.ts | 125 ++++++++++- src/AdminApp.tsx | 10 +- src/App.tsx | 16 +- src/components/AdminDashboard.tsx | 19 +- src/components/AdminMessagesView.tsx | 291 +++++++++++++++++++++++++ src/components/AdminPricingManager.tsx | 2 +- src/components/HelpView.tsx | 94 +++++++- src/components/Navbar.tsx | 27 ++- src/components/ProfileView.tsx | 4 +- src/components/SubscriptionView.tsx | 2 +- src/services/asaas.ts | 2 +- 12 files changed, 561 insertions(+), 44 deletions(-) create mode 100644 src/components/AdminMessagesView.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0bf2dc..d992644 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,7 @@ model User { moduleGrades ModuleGrade[] certificates Certificate[] unlockedCourses String[] @default([]) @map("unlocked_courses") + supportTickets SupportTicket[] @@map("users") } @@ -281,3 +282,15 @@ model SystemSettings { @@map("system_settings") } +model SupportTicket { + id String @id @default(uuid()) + userId String @map("user_id") + content String @db.Text + status String @default("OPEN") // OPEN, REPLIED + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("support_tickets") +} + diff --git a/server.ts b/server.ts index 7dca7c1..eb63235 100644 --- a/server.ts +++ b/server.ts @@ -2211,16 +2211,123 @@ async function startServer() { console.error('❌ Falha ao inicializar banco de dados:', dbError); } - if (process.env.NODE_ENV !== 'production') { - const { createServer: createViteServer } = await import('vite'); - const vite = await createViteServer({ - server: { middlewareMode: true }, - appType: 'spa', +// --- Suporte / Mensagens --- + +// Aluno envia um chamado de suporte/ajuda +app.post('/api/support-tickets', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + try { + const { content } = req.body; + if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' }); + + const ticket = await prisma.supportTicket.create({ + data: { + userId: req.user!.id, + content, + } }); - app.use(vite.middlewares); - } else { - const distPath = path.join(process.cwd(), 'dist'); - app.use(express.static(distPath)); + + res.status(201).json(ticket); + } catch (error) { + console.error('Error creating support ticket:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Admin puxa todos os chamados e anotações +app.get('/api/admin/messages', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { + try { + const tickets = await prisma.supportTicket.findMany({ + include: { + user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } } + }, + orderBy: { createdAt: 'desc' } + }); + + const notes = await prisma.note.findMany({ + include: { + user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } }, + lesson: { select: { title: true, module: { select: { course: { select: { title: true } } } } } } + }, + orderBy: { createdAt: 'desc' } + }); + + res.json({ tickets, notes }); + } catch (error) { + console.error('Error fetching admin messages:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Admin responde um chamado ou anotação +app.post('/api/admin/reply-message', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { + try { + const { userId, message, ticketId } = req.body; + if (!userId || !message) return res.status(400).json({ error: 'userId e message são obrigatórios' }); + + // 1. Criar notificação no portal (Sininho) + await prisma.notification.create({ + data: { + userId, + title: 'Nova mensagem de Suporte', + message, + type: 'info' + } + }); + + if (ticketId) { + await prisma.supportTicket.update({ + where: { id: ticketId }, + data: { status: 'REPLIED' } + }); + } + + // 2. Enviar WhatsApp via Evolution API se configurado + const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (settings?.whatsappConnected && settings.evolutionApiUrl && settings.evolutionApiKey && user?.whatsapp) { + try { + const cleanPhone = user.whatsapp.replace(/\D/g, ''); + if (cleanPhone.length >= 10) { + const numberId = `${cleanPhone}@s.whatsapp.net`; + const url = `${settings.evolutionApiUrl}/message/sendText/${settings.evolutionInstance}`; + + await axios.post(url, { + number: numberId, + text: message, + options: { delay: 1200, linkPreview: true } + }, { + headers: { + 'apikey': settings.evolutionApiKey, + 'Content-Type': 'application/json' + } + }); + console.log(`WhatsApp reply sent to ${cleanPhone}`); + } + } catch (waError: any) { + console.error('Failed to send WhatsApp reply:', waError?.response?.data || waError.message); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Error replying to message:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +if (process.env.NODE_ENV !== 'production') { + const { createServer: createViteServer } = await import('vite'); + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: 'spa', + }); + app.use(vite.middlewares); +} else { + const distPath = path.join(process.cwd(), 'dist'); + app.use(express.static(distPath)); + + // Fallback para SPA - Isso DEVE estar no final app.get('*', (req: Request, res: Response) => { res.sendFile(path.join(distPath, 'index.html')); }); diff --git a/src/AdminApp.tsx b/src/AdminApp.tsx index 78f3e55..704f2c4 100644 --- a/src/AdminApp.tsx +++ b/src/AdminApp.tsx @@ -7,7 +7,7 @@ import AdminDashboard from './components/AdminDashboard'; import AdminContentManager from './components/AdminContentManager'; export default function AdminApp() { - const [token, setToken] = useState(localStorage.getItem('devflix_admin_token')); + const [token, setToken] = useState(localStorage.getItem('admin_token')); const [adminUser, setAdminUser] = useState<{ name: string; email: string; @@ -88,7 +88,7 @@ export default function AdminApp() { throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.'); } - localStorage.setItem('devflix_admin_token', data.token); + localStorage.setItem('admin_token', data.token); setToken(data.token); setAdminUser(data.user); setLoginEmail(''); @@ -101,7 +101,7 @@ export default function AdminApp() { }; const handleLogout = () => { - localStorage.removeItem('devflix_admin_token'); + localStorage.removeItem('admin_token'); setToken(null); setAdminUser(null); }; @@ -131,7 +131,7 @@ export default function AdminApp() {

- {(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX ADMIN + {(settings?.brandName || 'Plataforma')}FLIX ADMIN

Autenticação obrigatória para gestores, administradores e moderadores.

@@ -156,7 +156,7 @@ export default function AdminApp() { required value={loginEmail} onChange={(e) => setLoginEmail(e.target.value)} - placeholder="email.admin@devflix.com" + placeholder="admin@email.com" className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none" /> diff --git a/src/App.tsx b/src/App.tsx index d963d4d..63078f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import HelpView from './components/HelpView'; import { User as UserType, Course as CourseType, SubscriptionStatus } from './types'; export default function App() { - const [token, setToken] = useState(localStorage.getItem('devflix_token')); + const [token, setToken] = useState(localStorage.getItem('app_token')); const [user, setUser] = useState<{ name: string; email: string; @@ -66,7 +66,7 @@ export default function App() { .then(data => { setSettings(data); if (data.brandName) { - document.title = `${data.brandName.charAt(0).toUpperCase() + data.brandName.slice(1)}Flix - Plataforma de Estudos`; + document.title = `${data.brandName}FLIX - Plataforma de Estudos`; } }) .catch(err => console.error('Error fetching settings:', err)); @@ -188,7 +188,7 @@ export default function App() { throw new Error(data.error || 'Erro ao efetuar login'); } - localStorage.setItem('devflix_token', data.token); + localStorage.setItem('app_token', data.token); setToken(data.token); setUser(data.user); setLoginEmail(''); @@ -218,7 +218,7 @@ export default function App() { throw new Error(data.error || 'Erro ao efetuar cadastro'); } - localStorage.setItem('devflix_token', data.token); + localStorage.setItem('app_token', data.token); setToken(data.token); setUser(data.user); setRegName(''); @@ -232,7 +232,7 @@ export default function App() { }; const handleLogout = () => { - localStorage.removeItem('devflix_token'); + localStorage.removeItem('app_token'); setToken(null); setUser(null); setView('home'); @@ -456,12 +456,12 @@ export default function App() { {/* Footer */}
-

{(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX - Plataforma de Cursos Profissionalizantes

+

{(settings?.brandName || 'Plataforma')}FLIX - Plataforma de Cursos Profissionalizantes

Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.

{settings?.cnpj && (

CNPJ: {settings.cnpj} • Endereço: {settings.address}

)} -

© 2026 {(settings?.brandName ? settings.brandName.charAt(0).toUpperCase() + settings.brandName.slice(1) : 'Microtec')}Flix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.

+

© 2026 {(settings?.brandName || 'Plataforma')}FLIX Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.

) : ( @@ -478,7 +478,7 @@ export default function App() {

- {(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX + {(settings?.brandName || 'Plataforma')}FLIX

{settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.'} diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index a2b7828..9b5d1d1 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -9,6 +9,7 @@ import { SubscriptionStatus } from '../types'; import { AdminPricingManager } from './AdminPricingManager'; import { WhatsappConnectCard } from './WhatsappConnectCard'; import AdminContentManager from './AdminContentManager'; +import AdminMessagesView from './AdminMessagesView'; interface WebhookLog { id: string; @@ -75,7 +76,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash const [expandedStudentId, setExpandedStudentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard'); + const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'messages' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard'); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); @@ -357,7 +358,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash - {(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX + {(settings?.brandName || 'Plataforma')}FLIX + {/* SISTEMA */} @@ -931,6 +939,11 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash )} + {/* TAB 5.5: MENSAGENS */} + {activeTab === 'messages' && ( + + )} + {/* TAB 6: ASAAS INTEGRATION CONFIG */} {activeTab === 'asaas_settings' && (

diff --git a/src/components/AdminMessagesView.tsx b/src/components/AdminMessagesView.tsx new file mode 100644 index 0000000..f8d6767 --- /dev/null +++ b/src/components/AdminMessagesView.tsx @@ -0,0 +1,291 @@ +import React, { useState, useEffect } from 'react'; +import { Mail, MessageSquare, BookOpen, Send, User, Reply } from 'lucide-react'; + +interface SupportTicket { + id: string; + content: string; + status: string; + createdAt: string; + user: { + id: string; + name: string; + email: string; + avatarUrl: string | null; + whatsapp: string | null; + }; +} + +interface Note { + id: string; + content: string; + createdAt: string; + user: { + id: string; + name: string; + email: string; + avatarUrl: string | null; + whatsapp: string | null; + }; + lesson: { + title: string; + module: { + course: { + title: string; + } + } + }; +} + +export default function AdminMessagesView() { + const [activeTab, setActiveTab] = useState<'tickets' | 'notes'>('tickets'); + const [tickets, setTickets] = useState([]); + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [replyText, setReplyText] = useState(''); + const [replyingTo, setReplyingTo] = useState(null); + const [isSending, setIsSending] = useState(false); + + useEffect(() => { + fetchMessages(); + }, []); + + const fetchMessages = async () => { + setIsLoading(true); + try { + const token = localStorage.getItem('admin_token'); + const res = await fetch('/api/admin/messages', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setTickets(data.tickets); + setNotes(data.notes); + } + } catch (error) { + console.error('Error fetching messages:', error); + } finally { + setIsLoading(false); + } + }; + + const handleReply = async (userId: string, ticketId?: string) => { + if (!replyText.trim()) return; + setIsSending(true); + try { + const token = localStorage.getItem('admin_token'); + const res = await fetch('/api/admin/reply-message', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ userId, message: replyText, ticketId }) + }); + if (res.ok) { + setReplyText(''); + setReplyingTo(null); + fetchMessages(); + alert('Resposta enviada com sucesso!'); + } else { + alert('Erro ao enviar resposta.'); + } + } catch (error) { + console.error('Error replying:', error); + alert('Erro interno ao enviar resposta.'); + } finally { + setIsSending(false); + } + }; + + return ( +
+
+
+

+ + Mensagens e Comentários +

+

Gerencie dúvidas, anotações de aulas e chamados de suporte dos alunos.

+
+
+ +
+ + +
+ + {isLoading ? ( +
+
+
+ ) : ( +
+ {activeTab === 'tickets' && ( + tickets.length === 0 ? ( +
Nenhum comentário recebido.
+ ) : ( + tickets.map(ticket => ( +
+ {ticket.status === 'REPLIED' && ( +
+ Respondido +
+ )} +
+
+ {ticket.user.avatarUrl ? ( + {ticket.user.name} + ) : ( + + )} +
+
+

{ticket.user.name}

+

{ticket.user.email} {ticket.user.whatsapp && `• WA: ${ticket.user.whatsapp}`}

+

{new Date(ticket.createdAt).toLocaleString('pt-BR')}

+
+
+ +
+ {ticket.content} +
+ + {replyingTo === ticket.id ? ( +
+