diff --git a/admin/Dockerfile b/admin/Dockerfile index 84ae0a4..cdbae12 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -5,7 +5,8 @@ RUN npm ci --omit=dev FROM node:20-alpine AS builder WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json* ./ +RUN npm ci COPY . . RUN npm run build diff --git a/admin/package-lock.json b/admin/package-lock.json index 70c0e5d..cdae542 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@types/node": "^22.15.21", + "@types/pg": "^8.11.10", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "lucide-react": "^0.511.0", @@ -647,6 +648,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", diff --git a/admin/package.json b/admin/package.json index 5e96644..20f41cd 100644 --- a/admin/package.json +++ b/admin/package.json @@ -15,6 +15,7 @@ "@types/node": "^22.15.21", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", + "@types/pg": "^8.11.10", "lucide-react": "^0.511.0", "pg": "^8.16.0" } diff --git a/admin/src/app/page.tsx b/admin/src/app/page.tsx index 5dfdce3..87223e4 100644 --- a/admin/src/app/page.tsx +++ b/admin/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; -import { LayoutDashboard, Users, CreditCard, Package, Settings, Shield, TrendingUp, Bell, LogOut } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { LayoutDashboard, Users, CreditCard, Package, Settings, Shield, TrendingUp, Bell, LogOut, User, CheckCircle, AlertCircle } from 'lucide-react'; import AdminDashboard from '@/components/AdminDashboard'; import Tenants from '@/components/Tenants'; import Plans from '@/components/Plans'; @@ -26,8 +26,79 @@ const pages: Record = { settings: { title: 'Configurações', subtitle: 'Configurações do sistema' }, }; +const mockNotifications = [ + { id: 1, title: 'Nova Assinatura', message: 'Barbearia do Zé contratou o plano Starter', type: 'success', time: '5 min atrás', read: false }, + { id: 2, title: 'Falha no Pagamento', message: 'Salão Glamour não conseguiu renovar a assinatura', type: 'warning', time: '1 hora atrás', read: true }, + { id: 3, title: 'Ticket de Suporte', message: 'Novo chamado aberto por Studio Bella Nails', type: 'info', time: '2 horas atrás', read: true }, +]; + export default function AdminPage() { const [currentPage, setCurrentPage] = useState('dashboard'); + + const [showNotifications, setShowNotifications] = useState(false); + const [showProfile, setShowProfile] = useState(false); + const [notifications, setNotifications] = useState(mockNotifications); + + const notifRef = useRef(null); + const profileRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false); + if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false); + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const playNotificationSound = () => { + try { + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); // A5 + oscillator.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.1); // A6 + + gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.3); + } catch (e) { + console.log('Audio not supported or blocked'); + } + }; + + const handleNotificationClick = () => { + if (!showNotifications) { + const unreadCount = notifications.filter(n => !n.read).length; + if (unreadCount > 0) { + playNotificationSound(); + } + } + setShowNotifications(!showNotifications); + setShowProfile(false); + }; + + const handleProfileClick = () => { + setShowProfile(!showProfile); + setShowNotifications(false); + }; + + const markAllAsRead = () => { + setNotifications(notifications.map(n => ({ ...n, read: true }))); + }; + + const logout = () => { + window.location.href = '/'; + }; + + const unreadCount = notifications.filter(n => !n.read).length; const renderPage = () => { switch (currentPage) { @@ -85,11 +156,77 @@ export default function AdminPage() {

{pages[currentPage]?.subtitle}

- -
SA
+
+ + + {showNotifications && ( +
+
+

Notificações

+ {unreadCount > 0 && ( + + )} +
+
+ {notifications.map(n => ( +
+
+ {n.type === 'success' ? : n.type === 'warning' ? : } +
+
+
{n.title}
+
{n.message}
+
{n.time}
+
+
+ ))} +
+
+ )} +
+ +
+
SA
+ + {showProfile && ( +
+
+
Super Admin
+
admin@agendapro.com
+
+ + + +
+ )} +
diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 73cedbc..433bc3b 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react'; -import { Bell, Menu, Search } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { Bell, Menu, Search, LogOut, User, CheckCircle, AlertCircle } from 'lucide-react'; interface TopbarProps { title: string; @@ -7,17 +7,94 @@ interface TopbarProps { onMenuClick: () => void; } +const mockNotifications = [ + { id: 1, title: 'Novo Agendamento', message: 'João Silva marcou Cabelo às 14:00', type: 'info', time: '10 min atrás', read: false }, + { id: 2, title: 'Pagamento Recebido', message: 'Assinatura renovada com sucesso', type: 'success', time: '2 horas atrás', read: true }, + { id: 3, title: 'Estoque Baixo', message: 'Pomada modeladora abaixo de 5 unidades', type: 'warning', time: '1 dia atrás', read: true }, +]; + export default function Topbar({ title, subtitle, onMenuClick }: TopbarProps) { const [avatarInitials, setAvatarInitials] = useState('AD'); + const [tenantName, setTenantName] = useState('Administrador'); + const [email, setEmail] = useState('admin@agendapro.com'); + + const [showNotifications, setShowNotifications] = useState(false); + const [showProfile, setShowProfile] = useState(false); + const [notifications, setNotifications] = useState(mockNotifications); + + const notifRef = useRef(null); + const profileRef = useRef(null); useEffect(() => { const savedName = localStorage.getItem('agendapro_tenant_name'); if (savedName) { + setTenantName(savedName); const initials = savedName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase(); setAvatarInitials(initials || 'BJ'); } + const savedEmail = localStorage.getItem('agendapro_email'); + if (savedEmail) setEmail(savedEmail); + + const handleClickOutside = (e: MouseEvent) => { + if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false); + if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false); + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + const playNotificationSound = () => { + try { + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); // A5 + oscillator.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.1); // A6 + + gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.3); + } catch (e) { + console.log('Audio not supported or blocked'); + } + }; + + const handleNotificationClick = () => { + if (!showNotifications) { + // Simulate receiving a new notification and playing sound the first time they open + const unreadCount = notifications.filter(n => !n.read).length; + if (unreadCount > 0) { + playNotificationSound(); + } + } + setShowNotifications(!showNotifications); + setShowProfile(false); + }; + + const handleProfileClick = () => { + setShowProfile(!showProfile); + setShowNotifications(false); + }; + + const markAllAsRead = () => { + setNotifications(notifications.map(n => ({ ...n, read: true }))); + }; + + const logout = () => { + localStorage.removeItem('agendapro_tenant_slug'); + localStorage.removeItem('agendapro_tenant_name'); + window.location.href = '/'; + }; + + const unreadCount = notifications.filter(n => !n.read).length; + return (
@@ -29,20 +106,90 @@ export default function Topbar({ title, subtitle, onMenuClick }: TopbarProps) {

{subtitle}

+
- - + + +
+ +
+ + + {showNotifications && ( +
+
+

Notificações

+ {unreadCount > 0 && ( + + )} +
+
+ {notifications.map(n => ( +
+
+ {n.type === 'success' ? : n.type === 'warning' ? : } +
+
+
{n.title}
+
{n.message}
+
{n.time}
+
+
+ ))} +
+
+ )} +
+ +
+
+ {avatarInitials} +
+ + {showProfile && ( +
+
+
{tenantName}
+
{email}
+
+ + + +
+ )}
- -
{avatarInitials}
);