diff --git a/index.html b/index.html new file mode 100644 index 0000000..fd7c215 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + SteelPaint + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..61f5004 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "gpi-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"", + "build:client": "vite build", + "build:server": "tsc -p tsconfig.server.json", + "build": "npm run build:client && npm run build:server", + "lint": "eslint .", + "preview": "vite preview", + "start": "node dist/server/index.js" + }, + "dependencies": { + "@clerk/clerk-react": "^5.59.6", + "@clerk/localizations": "^3.35.3", + "@tailwindcss/postcss": "^4.1.18", + "@types/mongoose": "^5.11.96", + "@types/uuid": "^10.0.0", + "@vercel/speed-insights": "^1.3.1", + "axios": "^1.13.2", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "date-fns": "^4.1.0", + "dequal": "^2.0.3", + "dotenv": "^17.2.3", + "enhanced-resolve": "^5.18.4", + "express": "^5.2.1", + "lucide-react": "^0.562.0", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "multer": "^2.0.2", + "pdf-parse": "^1.1.1", + "prop-types": "^15.8.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-is": "^19.2.3", + "react-router-dom": "^7.12.0", + "recharts": "^3.7.0", + "search-web": "^1.0.3", + "serverless-http": "^4.0.0", + "tailwind-merge": "^3.4.0", + "tesseract.js": "^7.0.0", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/multer": "^2.0.0", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vercel/node": "^5.5.28", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", + "concurrently": "^9.1.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "nodemon": "^3.1.11", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "ts-node": "^10.9.2", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vite-plugin-pwa": "^1.2.0" + } +} diff --git a/src/client/App.css b/src/client/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/src/client/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 0000000..eceb3a3 --- /dev/null +++ b/src/client/App.tsx @@ -0,0 +1,122 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { SignedIn, SignedOut, useOrganization } from '@clerk/clerk-react'; +import { AuthProvider } from './context/AuthContext'; +import { useAuth } from './context/useAuth'; +import { SystemSettingsProvider } from './context/SystemSettingsContext'; +import { NotificationProvider } from './contexts/NotificationContext'; +import { Layout } from './components/Layout'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { ToastProvider } from './components/Toast'; +import { ProjectList } from './pages/ProjectList'; +import { ProjectDetails } from './pages/ProjectDetails'; +import { SchemesList } from './pages/SchemesList'; +import { InspectionsList } from './pages/InspectionsList'; +import { DataSheetLibrary } from './pages/DataSheetLibrary'; +import { YieldStudyDashboard } from './pages/YieldStudyDashboard'; +import { AdminDashboard } from './pages/AdminDashboard'; +import { DeveloperDashboard } from './pages/DeveloperDashboard'; +import { CalculatorDashboard } from './pages/CalculatorDashboard'; +import { StockDashboard } from './pages/StockDashboard'; +import { GuestDashboard } from './pages/GuestDashboard'; +import { Login } from './pages/Login'; +import { OrganizationSelector } from './pages/OrganizationSelector'; +import InstrumentList from './pages/InstrumentList'; + +const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { isDeveloper, isLoading } = useAuth(); + + if (isLoading) return null; + if (!isDeveloper()) return ; + + return <>{children}; +}; + +const AppContent: React.FC = () => { + const { organization } = useOrganization(); + + console.log('AppContent rendered'); + console.log('Current organization:', organization); + + // If user is signed in but has no organization, show org selector + if (!organization) { + console.log('No organization - showing OrganizationSelector'); + return ; + } + + console.log('Organization exists - showing main app'); + return ( + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + + } /> + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + + ); +}; + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/src/client/assets/grade.jpg b/src/client/assets/grade.jpg new file mode 100644 index 0000000..8e50c9e Binary files /dev/null and b/src/client/assets/grade.jpg differ diff --git a/src/client/assets/react.svg b/src/client/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/client/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/client/components/ActiveUsers.tsx b/src/client/components/ActiveUsers.tsx new file mode 100644 index 0000000..77790a3 --- /dev/null +++ b/src/client/components/ActiveUsers.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { usePresence } from '../hooks/usePresence'; + +export const ActiveUsers: React.FC = () => { + const { activeUsers } = usePresence(); + + // Filter out current user from display if desired, or keep them to show connectivity. + // The backend `getActiveUsers` optionally excludes self, but let's handle display logic here. + // Backend logic: _id: { $ne: currentUserId } + // So activeUsers only contains OTHER users. + + if (activeUsers.length === 0) return null; + + return ( +
+
+ {activeUsers.map((u) => ( +
+
+ {u.name.charAt(0)} +
+ + + {/* Tooltip */} +
+ {u.name} +
+
+ ))} +
+ {activeUsers.length > 3 && ( + +{activeUsers.length} + )} +
+ ); +}; diff --git a/src/client/components/AdhesionGradeSelect.tsx b/src/client/components/AdhesionGradeSelect.tsx new file mode 100644 index 0000000..c9a27fc --- /dev/null +++ b/src/client/components/AdhesionGradeSelect.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { HelpCircle, X } from 'lucide-react'; +import gradeImage from '../assets/grade.jpg'; + +interface AdhesionGrade { + value: string; + label: string; + areaRemoved: string; + status: 'approved' | 'warning' | 'rejected' | 'critical'; + statusLabel: string; + description: string; + spriteY: number; +} + +const adhesionGrades: AdhesionGrade[] = [ + { + value: '5B', + label: '5B / 5Y', + areaRemoved: '0%', + status: 'approved', + statusLabel: 'Aprovado', + description: 'As bordas dos cortes estão completamente lisas; nenhum quadradinho da grade se soltou. A grade parece intacta, apenas riscos finos.', + spriteY: 14 + }, + { + value: '4B', + label: '4B / 4Y', + areaRemoved: '< 5%', + status: 'approved', + statusLabel: 'Geralmente Aprovado', + description: 'Pequenas lascas de tinta se soltaram nas interseções dos cortes. A área afetada é inferior a 5% da área total da grade.', + spriteY: 29 + }, + { + value: '3B', + label: '3B / 3Y', + areaRemoved: '5 - 15%', + status: 'warning', + statusLabel: 'Limite Aceitável', + description: 'Pequenas lascas se soltaram ao longo das bordas e nas interseções. As linhas de corte parecem irregulares e alguns cantinhos dos quadrados sumiram.', + spriteY: 44 + }, + { + value: '2B', + label: '2B / 2Y', + areaRemoved: '15 - 35%', + status: 'rejected', + statusLabel: 'Geralmente Reprovado', + description: 'A tinta descascou ao longo das bordas e em partes dos quadrados. É visível que a tinta está falhando; faixas inteiras ao lado dos cortes podem ter saído.', + spriteY: 59 + }, + { + value: '1B', + label: '1B / 1Y', + areaRemoved: '35 - 65%', + status: 'rejected', + statusLabel: 'Reprovado', + description: 'A tinta descascou em fitas largas ou quadrados inteiros se soltaram. A grade está muito danificada, com grandes buracos.', + spriteY: 74 + }, + { + value: '0B', + label: '0B / 0Y', + areaRemoved: '> 65%', + status: 'critical', + statusLabel: 'Reprovado Crítico', + description: 'A descamação e remoção é pior que o grau 1B (mais de 65% da área). A maior parte da tinta na área do teste foi arrancada pela fita.', + spriteY: 89 + } +]; + +interface AdhesionGradeSelectProps { + name: string; + label?: string; + value: string; + onChange: (e: React.ChangeEvent) => void; +} + +export const AdhesionGradeSelect: React.FC = ({ name, label, value, onChange }) => { + const [showGuide, setShowGuide] = useState(false); + + const getStatusColor = (status: AdhesionGrade['status']) => { + switch (status) { + case 'approved': return 'text-green-400 bg-green-500/20 border-green-500/30'; + case 'warning': return 'text-amber-400 bg-amber-500/20 border-amber-500/30'; + case 'rejected': return 'text-red-400 bg-red-500/20 border-red-500/30'; + case 'critical': return 'text-red-500 bg-red-600/30 border-red-600/40'; + default: return 'text-gray-400 bg-gray-500/20 border-gray-500/30'; + } + }; + + return ( +
+ {/* Label with help button */} +
+ {label && } + +
+ + + + {/* Guide Modal */} + {showGuide && ( + <> + {/* Backdrop */} +
setShowGuide(false)} + /> + + {/* Modal */} +
+
+ {/* Header */} +
+
+

Guia de Classificação ASTM D3359

+

Método B - Teste de Aderência de Corte em Grade

+
+ +
+ + {/* Content */} +
+ {/* Grade Image */} +
+ Guia Visual ASTM D3359 +
+ + {/* Grade Cards */} +
+ {adhesionGrades.map((grade) => ( +
+
+ {grade.value} + + {grade.areaRemoved} + +
+

{grade.statusLabel}

+

+ {grade.description} +

+
+ ))} +
+ + {/* Info Note */} +
+

+ Nota: A escala ASTM funciona como uma "nota escolar": + 5B é a nota máxima (perfeito) e + 0B é a nota mínima (reprovado). +

+
+
+
+
+ + )} +
+ ); +}; diff --git a/src/client/components/ArchivedNotificationsModal.tsx b/src/client/components/ArchivedNotificationsModal.tsx new file mode 100644 index 0000000..e230689 --- /dev/null +++ b/src/client/components/ArchivedNotificationsModal.tsx @@ -0,0 +1,131 @@ +import React, { useState, useEffect } from 'react'; +import { X, Archive, Trash2, RefreshCcw } from 'lucide-react'; +import api from '../services/api'; +import type { INotification } from '../types'; + +interface ArchivedNotificationsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ArchivedNotificationsModal: React.FC = ({ + isOpen, + onClose, +}) => { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchArchived = async () => { + setIsLoading(true); + try { + const response = await api.get('/notifications?includeArchived=true'); + // Filtrar apenas as arquivadas (no frontend por segurança, embora o backend já devesse ajudar) + // Na verdade, passamos includeArchived=true, o backend retornará unread + archived. + // Vamos filtrar para mostrar apenas o "Log" (arquivadas). + setNotifications(response.data.filter(n => n.isArchived || n.archivedBy?.length > 0)); + } catch (error) { + console.error('Error fetching archived notifications:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + fetchArchived(); + } + }, [isOpen]); + + const deleteForever = async (id: string) => { + if (!window.confirm('Excluir permanentemente este registro do log?')) return; + try { + await api.delete(`/notifications/${id}`); + setNotifications(prev => prev.filter(n => n._id !== id)); + } catch (error) { + console.error('Error deleting archived notification:', error); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Log de Mensagens (Arquivadas)

+

+ Histórico de notificações sistema +

+
+
+ +
+ + {/* Body */} +
+ {isLoading ? ( +
+ +

Carregando histórico...

+
+ ) : notifications.length === 0 ? ( +
+ +

Nenhuma mensagem arquivada

+

+ Mensagens arquivadas aparecerão aqui para consulta. +

+
+ ) : ( +
+ {notifications.map((msg) => ( +
+
+
+ {msg.title} + + {new Date(msg.createdAt).toLocaleString('pt-BR')} + +
+ +
+

{msg.message}

+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/src/client/components/Button.tsx b/src/client/components/Button.tsx new file mode 100644 index 0000000..70082d5 --- /dev/null +++ b/src/client/components/Button.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; +} + +export const Button: React.FC = ({ + className, + variant = 'primary', + size = 'md', + children, + ...props +}) => { + const baseStyles = 'inline-flex items-center justify-center rounded-xl font-bold transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 tracking-wide'; + const variants = { + primary: 'bg-primary text-background-dark shadow-lg shadow-primary/20 hover:shadow-glow-primary hover:bg-primary/90 focus:ring-primary font-black', + secondary: 'bg-surface-highlight/50 backdrop-blur-sm text-text-main border border-white/10 hover:bg-surface-hover hover:border-text-muted/30 focus:ring-accent', + success: 'bg-accent-green text-background-dark shadow-lg shadow-accent-green/20 hover:shadow-[0_0_20px_rgba(11,218,77,0.4)] hover:bg-accent-green/90 focus:ring-accent-green font-black', + danger: 'bg-error text-white shadow-lg shadow-error/20 hover:shadow-glow-error hover:bg-error/90 focus:ring-error font-bold', + ghost: 'bg-transparent hover:bg-white/5 text-text-muted hover:text-white' + }; + + const sizes = { + sm: 'h-9 px-4 text-xs', + md: 'h-11 px-6 py-2.5 text-sm', + lg: 'h-14 px-8 text-base shadow-xl' + }; + + return ( + + ); +}; diff --git a/src/client/components/Card.tsx b/src/client/components/Card.tsx new file mode 100644 index 0000000..66f7bbc --- /dev/null +++ b/src/client/components/Card.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface CardProps { + children: React.ReactNode; + className?: string; + title?: string; + description?: string; + actions?: React.ReactNode; + onClick?: () => void; +} + +export const Card: React.FC = ({ children, className, title, description, actions, onClick }) => { + return ( +
+ {(title || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ )} +
{children}
+
+ ); +}; + diff --git a/src/client/components/ColorBubble.tsx b/src/client/components/ColorBubble.tsx new file mode 100644 index 0000000..d41ac47 --- /dev/null +++ b/src/client/components/ColorBubble.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef } from 'react'; +import { clsx } from 'clsx'; + +interface ColorBubbleProps { + colorHex?: string; + className?: string; + title?: string; +} + +export const ColorBubble: React.FC = ({ colorHex, className, title }) => { + const bubbleRef = useRef(null); + + useEffect(() => { + if (bubbleRef.current) { + if (colorHex) { + bubbleRef.current.style.setProperty('--bg-color', colorHex); + } else { + bubbleRef.current.style.removeProperty('--bg-color'); + } + } + }, [colorHex]); + + return ( +
+ ); +}; diff --git a/src/client/components/ConfirmModal.tsx b/src/client/components/ConfirmModal.tsx new file mode 100644 index 0000000..300dcc7 --- /dev/null +++ b/src/client/components/ConfirmModal.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Modal } from './Modal'; +import { Button } from './Button'; +import { AlertTriangle, HelpCircle } from 'lucide-react'; + +interface ConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + type?: 'danger' | 'warning' | 'info'; +} + +export const ConfirmModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmText = 'Confirmar', + cancelText = 'Cancelar', + type = 'info' +}) => { + const getIcon = () => { + switch (type) { + case 'danger': return ; + case 'warning': return ; + default: return ; + } + }; + + return ( + +
+
+
+ {getIcon()} +
+
+

+ {description} +

+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/client/components/Input.tsx b/src/client/components/Input.tsx new file mode 100644 index 0000000..523ae5b --- /dev/null +++ b/src/client/components/Input.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input = React.forwardRef(({ className, label, error, ...props }, ref) => { + return ( +
+ {label && } + + {error && {error}} +
+ ); +}); diff --git a/src/client/components/Layout.tsx b/src/client/components/Layout.tsx new file mode 100644 index 0000000..1d6c29b --- /dev/null +++ b/src/client/components/Layout.tsx @@ -0,0 +1,418 @@ +import React, { useState } from 'react'; +import NotificationBell from './NotificationBell'; +import { TeamPresence } from './TeamPresence'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer } from 'lucide-react'; +import { clsx } from 'clsx'; +import { useClerk, UserButton, useUser, OrganizationSwitcher, useOrganization } from '@clerk/clerk-react'; +import { TechnicalManual } from './TechnicalManual'; +import { useAuth } from '../context/useAuth'; +// import { useSystemSettings } from '../context/SystemSettingsContext'; +import { setApiOrgData } from '../services/api'; + +interface LayoutProps { + children: React.ReactNode; +} + +export const Layout: React.FC = ({ children }) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isManualOpen, setIsManualOpen] = useState(false); + const [isDarkMode, setIsDarkMode] = useState(() => { + const saved = localStorage.getItem('theme'); + return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); + }); + const location = useLocation(); + const { signOut } = useClerk(); + const { user } = useUser(); + const { organization } = useOrganization(); + const { isAdmin, isUser, isDeveloper, appUser } = useAuth(); + // const { settings } = useSystemSettings(); + + // Sync Organization ID with API client + React.useEffect(() => { + if (organization?.id) { + + setApiOrgData(organization.id, organization.name); + } else { + setApiOrgData(null); + } + }, [organization]); + + // Helper to get role display name + const getRoleDisplay = () => { + switch (appUser?.role) { + case 'admin': return { label: 'Admin', color: 'text-amber-500' }; + case 'user': return { label: 'Usuário', color: 'text-green-500' }; + case 'guest': return { label: 'Convidado', color: 'text-blue-400' }; + default: return { label: 'Carregando...', color: 'text-text-muted' }; + } + }; + const roleInfo = getRoleDisplay(); + + React.useEffect(() => { + if (isDarkMode) { + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } + }, [isDarkMode]); + + const toggleTheme = () => setIsDarkMode(!isDarkMode); + const navigate = useNavigate(); + + // Guest Redirection Logic + React.useEffect(() => { + if (appUser?.role === 'guest' && (location.pathname === '/' || location.pathname === '/projects')) { + navigate('/guest-dashboard'); + } + }, [appUser, location.pathname, navigate]); + + interface NavItem { + icon: React.ElementType; + label: string; + path: string; + adminOnly?: boolean; + } + + const navItems: NavItem[] = [ + { icon: LayoutDashboard, label: 'Painel Principal', path: '/guest-dashboard' }, + { icon: FolderOpen, label: 'Obras & Projetos', path: '/' }, + { icon: Layers, label: 'Esquemas', path: '/schemes' }, + { icon: ClipboardCheck, label: 'Inspeções', path: '/inspections' }, + { icon: FolderOpen, label: 'Biblioteca', path: '/library', adminOnly: true }, + { icon: Thermometer, label: 'Instrumentos', path: '/instruments' }, + { icon: TrendingUp, label: 'Estudo Rend.', path: '/yield-study', adminOnly: true }, + { icon: Package, label: 'Estoque', path: '/stock' }, + { icon: Wrench, label: 'Calculadora', path: '/calculators' }, + ]; + + const isActive = (path: string) => { + if (path === '/' && location.pathname === '/') return true; + if (path !== '/' && location.pathname.startsWith(path)) return true; + return false; + }; + + return ( +
+ {/* Sidebar Desktop - Fixed */} + + + {/* Mobile Header */} +
+
+ Logo + SteelPaint +
+ +
+ + {/* Mobile Sidebar Overlay */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)} + /> + )} + + {/* Mobile Sidebar Drawer */} +
+
+
+ Logo + SteelPaint +
+ +
+ +
+ +
+
+ + {/* Main Content Area */} +
+
+ {children} +
+
+ + {/* Floating Help Button */} + + + {/* Technical Manual Modal */} + setIsManualOpen(false)} /> +
+ ); +}; + diff --git a/src/client/components/MobileList.tsx b/src/client/components/MobileList.tsx new file mode 100644 index 0000000..b47e571 --- /dev/null +++ b/src/client/components/MobileList.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Card } from './Card'; + +interface Column { + header: string; + accessor: keyof T | ((item: T) => React.ReactNode); + className?: string; // For adding widths or hiding on smaller screens +} + +interface MobileListProps { + data: T[]; + columns: Column[]; + keyExtractor: (item: T) => string; + onItemClick?: (item: T) => void; + titleAccessor?: keyof T | ((item: T) => React.ReactNode); + subtitleAccessor?: keyof T | ((item: T) => React.ReactNode); + actionRender?: (item: T) => React.ReactNode; +} + +export const MobileList = ({ + data, + columns, + keyExtractor, + onItemClick, + titleAccessor, + subtitleAccessor, + actionRender +}: MobileListProps) => { + + const renderCell = (item: T, col: Column) => { + if (typeof col.accessor === 'function') { + return col.accessor(item); + } + return item[col.accessor] as React.ReactNode; + }; + + const getTitle = (item: T) => { + if (!titleAccessor) return undefined; + if (typeof titleAccessor === 'function') return titleAccessor(item) as string; + return item[titleAccessor] as string; + }; + + const getSubtitle = (item: T) => { + if (!subtitleAccessor) return undefined; + if (typeof subtitleAccessor === 'function') return subtitleAccessor(item) as string; + return item[subtitleAccessor] as string; + }; + + return ( + <> + {/* Mobile View: Cards */} +
+ {data.map((item, index) => ( + onItemClick?.(item)} + title={getTitle(item)} + description={getSubtitle(item)} + actions={actionRender?.(item)} + className="active:bg-surface-hover" + > +
+ {columns.map((col, colIndex) => { + // Skip if it's the title or subtitle to avoid duplication (optional logic) + return ( +
+ {col.header}: + {renderCell(item, col)} +
+ ); + })} +
+
+ ))} +
+ + {/* Desktop View: Table */} +
+ + + + {columns.map((col, idx) => ( + + ))} + {actionRender && } + + + + {data.map((item, index) => ( + onItemClick?.(item)} + className="hover:bg-primary/[0.02] transition-colors cursor-pointer group" + > + {columns.map((col, idx) => ( + + ))} + {actionRender && ( + + )} + + ))} + +
+ {col.header} + Ações
+ {renderCell(item, col)} + + {actionRender(item)} +
+
+ + ); +}; diff --git a/src/client/components/Modal.tsx b/src/client/components/Modal.tsx new file mode 100644 index 0000000..d7648e5 --- /dev/null +++ b/src/client/components/Modal.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + maxWidth?: string; +} + +export const Modal: React.FC = ({ isOpen, onClose, title, children, maxWidth = 'max-w-2xl' }) => { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
+ {children} +
+
+
+ ); +}; diff --git a/src/client/components/NotificationBell.tsx b/src/client/components/NotificationBell.tsx new file mode 100644 index 0000000..67df66b --- /dev/null +++ b/src/client/components/NotificationBell.tsx @@ -0,0 +1,199 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useNotifications } from '../hooks/useNotifications'; +import { Bell, Check, Info, AlertTriangle, AlertCircle, CheckCircle, Trash2, Archive } from 'lucide-react'; +import type { NotificationType, INotification } from '../types'; +import { ArchivedNotificationsModal } from './ArchivedNotificationsModal'; + +const NotificationBell: React.FC = () => { + const { + unreadCount, + notifications, + markAsRead, + markAllAsRead, + clearAll, + archiveNotification, + deleteNotification + } = useNotifications(); + const [isOpen, setIsOpen] = useState(false); + const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); + const [confirmActionId, setConfirmActionId] = useState(null); + const popoverRef = useRef(null); + + // Close popover when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [popoverRef]); + + const getIcon = (type: NotificationType) => { + switch (type) { + case 'info': return ; + case 'warning': return ; + case 'error': return ; + case 'success': return ; + default: return ; + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
+ + + {isOpen && ( +
+
+

Notificações

+
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} + +
+
+ +
+ {notifications.length === 0 ? ( +
+ Nenhuma notificação. +
+ ) : ( +
    + {notifications.map((notification: INotification) => ( +
  • +
    + {getIcon(notification.type)} +
    +
    +
    + + {notification.title} + + + {formatDate(notification.createdAt)} + +
    +
    + {!notification.isRead ? ( + + ) :
    } + + +
    + + {/* Prompt de Arquivar/Excluir */} + {confirmActionId === notification._id && ( +
    +

    O que deseja fazer com esta mensagem?

    +
    + + +
    + +
    + )} +
    +
  • + ))} +
+ )} +
+
+ )} + + setIsArchiveModalOpen(false)} + /> +
+ ); +}; + +export default NotificationBell; diff --git a/src/client/components/PhotoUpload.tsx b/src/client/components/PhotoUpload.tsx new file mode 100644 index 0000000..5ebcd23 --- /dev/null +++ b/src/client/components/PhotoUpload.tsx @@ -0,0 +1,112 @@ +import React, { useRef, useState } from 'react'; +import { Camera, X } from 'lucide-react'; +import api from '../services/api'; + +interface PhotoUploadProps { + photos: string[]; + onPhotosChange: (url: string) => void; + onRemovePhoto: (index: number) => void; +} + +export const PhotoUpload: React.FC = ({ photos, onPhotosChange, onRemovePhoto }) => { + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validation: Max 500KB + if (file.size > 500 * 1024) { + alert('A foto deve ter no máximo 500KB.'); + if (fileInputRef.current) fileInputRef.current.value = ''; + return; + } + + // Validation: JPG only + if (file.type !== 'image/jpeg' && file.type !== 'image/jpg') { + alert('Apenas fotos no formato JPG são permitidas.'); + if (fileInputRef.current) fileInputRef.current.value = ''; + return; + } + + setUploading(true); + const formData = new FormData(); + formData.append('photo', file); + + try { + const response = await api.post('/inspections/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + onPhotosChange(response.data.url); + } catch (error) { + console.error('Error uploading photo:', error); + alert('Erro ao enviar foto. Verifique se o tamanho é menor que 500KB.'); + } finally { + setUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + return ( +
+ Fotos da Inspeção + +
+ {photos.map((photo, index) => ( +
+ {`Evidência +
+ +
+
+ ))} + + +
+ + +
+ ); +}; diff --git a/src/client/components/ProtectedRoute.tsx b/src/client/components/ProtectedRoute.tsx new file mode 100644 index 0000000..302ec86 --- /dev/null +++ b/src/client/components/ProtectedRoute.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/useAuth'; +import type { UserRole } from '../types'; +import { RefreshCw } from 'lucide-react'; + +interface ProtectedRouteProps { + children: React.ReactNode; + allowedRoles?: UserRole[]; + requireEdit?: boolean; + redirectTo?: string; +} + +/** + * ProtectedRoute component that restricts access based on user role + * @param allowedRoles - Array of roles that can access the route + * @param requireEdit - If true, only users who can edit (not guests) can access + * @param redirectTo - Where to redirect if access is denied (default: '/') + */ +export const ProtectedRoute: React.FC = ({ + children, + allowedRoles, + requireEdit = false, + redirectTo = '/', +}) => { + const { appUser, isLoading, canEdit } = useAuth(); + + // Show loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // Check role-based access + if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) { + return ; + } + + // Check edit permission + if (requireEdit && !canEdit()) { + return ; + } + + return <>{children}; +}; diff --git a/src/client/components/Select.tsx b/src/client/components/Select.tsx new file mode 100644 index 0000000..01a23c3 --- /dev/null +++ b/src/client/components/Select.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface SelectProps extends React.SelectHTMLAttributes { + label?: string; + error?: string; + options: { label: string; value: string | number }[]; +} + +export const Select = React.forwardRef(({ className, label, error, options, ...props }, ref) => { + return ( +
+ {label && } + + {error && {error}} +
+ ); +}); diff --git a/src/client/components/SendMessageModal.tsx b/src/client/components/SendMessageModal.tsx new file mode 100644 index 0000000..2917881 --- /dev/null +++ b/src/client/components/SendMessageModal.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { X, Send, Trash2 } from 'lucide-react'; +import api from '../services/api'; + +interface SendMessageModalProps { + isOpen: boolean; + onClose: () => void; + recipientId: string; + recipientName: string; + existingMessage?: { + id: string; + message: string; + }; + onMessageSent: () => void; +} + +export const SendMessageModal: React.FC = ({ + isOpen, + onClose, + recipientId, + recipientName, + existingMessage, + onMessageSent, +}) => { + const [message, setMessage] = useState(existingMessage?.message || ''); + const [isSending, setIsSending] = useState(false); + + if (!isOpen) return null; + + const handleSend = async () => { + if (!message.trim()) { + alert('Digite uma mensagem primeiro'); + return; + } + + setIsSending(true); + try { + await api.post('/messages', { + toUserId: recipientId, + message: message.trim(), + }); + alert('Mensagem enviada com sucesso!'); + onMessageSent(); + onClose(); + } catch (error) { + console.error('Error sending message:', error); + alert('Erro ao enviar mensagem'); + } finally { + setIsSending(false); + } + }; + + const handleDelete = async () => { + if (!existingMessage?.id) return; + + if (!confirm('Deseja realmente deletar esta mensagem?')) return; + + setIsSending(true); + try { + await api.delete(`/messages/${existingMessage.id}`); + alert('Mensagem deletada com sucesso!'); + onMessageSent(); + onClose(); + } catch (error) { + console.error('Error deleting message:', error); + alert('Erro ao deletar mensagem'); + } finally { + setIsSending(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+

+ {existingMessage ? 'Editar Mensagem' : 'Enviar Mensagem'} +

+

+ Para: {recipientName} +

+
+ +
+ + {/* Body */} +
+
+ +