feat: migrate vanilla js to react vite spa
continuous-integration/webhook Falha no deploy (VPS4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-30 18:26:56 +02:00
parent 3bc1161d95
commit d6ae290e56
47 changed files with 5374 additions and 24 deletions
+11
View File
@@ -0,0 +1,11 @@
# Dependências
node_modules/
# Build gerado pelo Vite (gerado pelo deploy-prod.sh, não versionar)
dist/
# Cache do Vite
.vite/
# Variáveis de ambiente locais
.env.local
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Sistema de gerenciamento de imagens de raio-X dental" />
<title>RF Dental — Gerenciador de Imagens</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "dental-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.1"
}
}
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea"/>
<stop offset="100%" style="stop-color:#764ba2"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
<!-- Tooth shape -->
<path d="M32 9 C24 9 18 13 16 19 C14 25 15.5 30 17 34 C18.5 38 19 41 18.5 46 C18 51 19 57 24 57 C27 57 28.5 53 28.5 50 C28.5 47 30 45 32 45 C34 45 35.5 47 35.5 50 C35.5 53 37 57 40 57 C45 57 46 51 45.5 46 C45 41 45.5 38 47 34 C48.5 30 50 25 48 19 C46 13 40 9 32 9Z"
fill="white" opacity="0.95"/>
<!-- Root hints -->
<path d="M27 46 C27 50 25 55 24 57" stroke="#764ba2" stroke-width="1.5" fill="none" opacity="0.3"/>
<path d="M37 46 C37 50 39 55 40 57" stroke="#764ba2" stroke-width="1.5" fill="none" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 894 B

@@ -0,0 +1 @@
1.0.0
+66
View File
@@ -0,0 +1,66 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { SocketProvider } from './contexts/SocketContext';
import { ToastProvider } from './contexts/ToastContext';
import { ProtectedRoute, AdminRoute } from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import InstallPage from './pages/InstallPage';
import DashboardPage from './pages/DashboardPage';
import ClientsPage from './pages/ClientsPage';
import AdminClinicsPage from './pages/AdminClinicsPage';
import ResetPage from './pages/ResetPage';
import DownloadPage from './pages/DownloadPage';
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<SocketProvider>
<Routes>
{/* Rotas públicas */}
<Route path="/login" element={<LoginPage />} />
<Route path="/install" element={<InstallPage />} />
{/* Rotas protegidas (usuário autenticado) */}
<Route path="/" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
{/* Rotas de administrador */}
<Route path="/clients" element={
<AdminRoute>
<ClientsPage />
</AdminRoute>
} />
<Route path="/admin-clinics" element={
<AdminRoute>
<AdminClinicsPage />
</AdminRoute>
} />
<Route path="/reset" element={
<AdminRoute>
<ResetPage />
</AdminRoute>
} />
<Route path="/download" element={
<AdminRoute>
<DownloadPage />
</AdminRoute>
} />
{/* Catch-all: redireciona para home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SocketProvider>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
);
}
@@ -0,0 +1,31 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
});
// Interceptor: injeta o token JWT em cada requisição
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Interceptor: trata erros globais (401 → redireciona para login)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
localStorage.removeItem('is_admin');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
@@ -0,0 +1,69 @@
import { useState } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
export default function CreatePatientModal({ isOpen, onClose, onCreated }) {
const { showToast } = useToast();
const [form, setForm] = useState({ firstName: '', lastName: '', doctor: '', birthday: '', gender: '0', phone: '', remark: '' });
const [saving, setSaving] = useState(false);
const set = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.post('/patients', form);
showToast('Paciente criado com sucesso!', 'success');
setForm({ firstName: '', lastName: '', doctor: '', birthday: '', gender: '0', phone: '', remark: '' });
onClose();
onCreated?.();
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao criar paciente.', 'error');
} finally { setSaving(false); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Novo Paciente" large={false}>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Nome *</label>
<input type="text" required placeholder="Ex: João" value={form.firstName} onChange={set('firstName')} />
</div>
<div className="form-group">
<label>Sobrenome *</label>
<input type="text" required placeholder="Ex: Silva" value={form.lastName} onChange={set('lastName')} />
</div>
<div className="form-group">
<label>Dentista</label>
<input type="text" placeholder="Nome do dentista responsável" value={form.doctor} onChange={set('doctor')} />
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Data de Nascimento</label>
<input type="date" value={form.birthday} onChange={set('birthday')} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Gênero</label>
<select value={form.gender} onChange={set('gender')}>
<option value="0">Masculino</option>
<option value="1">Feminino</option>
</select>
</div>
</div>
<div className="form-group">
<label>Telefone</label>
<input type="text" placeholder="(11) 99999-9999" value={form.phone} onChange={set('phone')} />
</div>
<div className="form-group">
<label>Observações</label>
<textarea rows={3} placeholder="Informações adicionais..." value={form.remark} onChange={set('remark')} />
</div>
<button type="submit" className="btn btn-primary btn-block" style={{ marginTop: 10 }} disabled={saving}>
{saving ? 'Salvando...' : 'Salvar Paciente'}
</button>
</form>
</Modal>
);
}
@@ -0,0 +1,35 @@
import { useEffect } from 'react';
export default function Modal({ isOpen, onClose, title, children, large = true, actions = null, headerExtras = null }) {
// Fecha com ESC
useEffect(() => {
if (!isOpen) return;
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={`modal-content${large ? ' modal-large' : ''}`}>
<div className="modal-header">
<div className="modal-title-group">
<h2>{title}</h2>
{headerExtras}
</div>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{children}
</div>
{actions && (
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
{actions}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
export default function PluginsModal({ isOpen, onClose }) {
const { showToast } = useToast();
const [config, setConfig] = useState({
wasabiAccessKey: '', wasabiSecretKey: '',
wasabiBucket: '', wasabiRegion: '', wasabiEndpoint: '', enabled: false
});
const [saving, setSaving] = useState(false);
const loadConfig = async () => {
try {
const { data } = await api.get('/system/storage-config');
setConfig(data);
} catch { showToast('Erro ao carregar configurações do Wasabi.', 'error'); }
};
useEffect(() => {
if (isOpen) loadConfig();
}, [isOpen]);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.post('/system/storage-config', {
wasabiAccessKey: config.wasabiAccessKey,
wasabiSecretKey: config.wasabiSecretKey,
wasabiBucket: config.wasabiBucket,
wasabiRegion: config.wasabiRegion,
wasabiEndpoint: config.wasabiEndpoint
});
showToast('Configurações do Wasabi salvas com sucesso!', 'success');
loadConfig();
setTimeout(onClose, 1200);
} catch (e) {
const msg = e.response?.data?.error || 'Erro ao salvar configurações.';
if (msg.includes('Chave') || msg.includes('bucket') || msg.includes('Erro 403')) {
alert('⚠️ ' + msg);
} else {
showToast(msg, 'error');
}
} finally { setSaving(false); }
};
const set = (key) => (e) => setConfig((prev) => ({ ...prev, [key]: e.target.value }));
return (
<Modal isOpen={isOpen} onClose={onClose} title="🔌 Configuração do Wasabi Storage" large={false}>
<form onSubmit={handleSubmit}>
<div style={{ background: '#f8f9fa', padding: '12px 18px', borderRadius: 8, border: '1px solid #eee', marginBottom: 20, fontSize: '0.95rem', color: '#555' }}>
Configure as credenciais e bucket do <strong>Wasabi S3</strong> para habilitar o armazenamento na nuvem.
</div>
{config.enabled !== undefined && (
<div style={{
padding: 10, borderRadius: 6, fontSize: '0.9rem', marginBottom: 15,
background: config.enabled ? '#eafaf1' : '#fffbeb',
color: config.enabled ? '#27ae60' : '#d97706',
border: `1px solid ${config.enabled ? '#2ecc7133' : '#f59e0b33'}`
}}>
{config.enabled ? '🟢 Wasabi Storage está ATIVO e conectado.' : '🟡 Wasabi Storage está DESATIVADO (armazenamento local).'}
</div>
)}
<div className="form-group">
<label>Access Key *</label>
<input type="text" className="form-control" placeholder="Ex: AIPL5M4G..." required value={config.wasabiAccessKey} onChange={set('wasabiAccessKey')} />
</div>
<div className="form-group">
<label>Secret Key *</label>
<input type="password" className="form-control" placeholder="Sua Secret Key" required value={config.wasabiSecretKey} onChange={set('wasabiSecretKey')} />
</div>
<div className="form-group">
<label>Bucket Ativo *</label>
<input type="text" className="form-control" placeholder="Ex: meu-bucket-rx" required value={config.wasabiBucket} onChange={set('wasabiBucket')} />
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Região</label>
<input type="text" className="form-control" placeholder="Ex: us-east-1" value={config.wasabiRegion} onChange={set('wasabiRegion')} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Endpoint</label>
<input type="text" className="form-control" placeholder="Ex: s3.wasabisys.com" value={config.wasabiEndpoint} onChange={set('wasabiEndpoint')} />
</div>
</div>
<div className="form-actions" style={{ borderTop: '1px solid #eee', paddingTop: 15 }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Salvando...' : 'Salvar Configurações'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,48 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
// Protege rotas que requerem autenticação
export function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="loading" style={{ height: '100vh' }}>
<div className="spinner" />
<p>Verificando sessão...</p>
</div>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Protege rotas que requerem permissão de administrador
export function AdminRoute({ children }) {
const { user, isAdmin, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="loading" style={{ height: '100vh' }}>
<div className="spinner" />
<p>Verificando permissões...</p>
</div>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (!isAdmin) {
return <Navigate to="/" replace />;
}
return children;
}
@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
export default function SettingsModal({ isOpen, onClose }) {
const { showToast } = useToast();
const [currentPassword, setCurrentPassword] = useState('');
const [newUsername, setNewUsername] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
setCurrentPassword('');
setNewUsername(localStorage.getItem('username') || '');
setNewEmail(localStorage.getItem('email') || '');
setNewPassword('');
}
}, [isOpen]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!currentPassword) { showToast('A senha atual é obrigatória.', 'error'); return; }
if (!newUsername && !newEmail && !newPassword) { showToast('Preencha ao menos um campo para alterar.', 'error'); return; }
setSaving(true);
try {
const { data } = await api.put('/auth/update-credentials', {
currentPassword, newUsername, newEmail, newPassword
});
if (data.token) {
localStorage.setItem('auth_token', data.token);
if (data.user) {
localStorage.setItem('username', data.user.username || '');
localStorage.setItem('email', data.user.email || '');
}
}
showToast('Credenciais atualizadas com sucesso!', 'success');
onClose();
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao atualizar credenciais.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="⚙️ Configurações da Conta" large={false}>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Senha Atual (Obrigatório)*</label>
<input type="password" className="form-control" placeholder="Digite sua senha atual" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
</div>
<hr style={{ margin: '20px 0', border: 'none', borderTop: '1px solid #eee' }} />
<p style={{ marginBottom: 15, color: '#666', fontSize: '0.9rem' }}>Preencha apenas os campos que deseja alterar:</p>
<div className="form-group">
<label>Novo Nome de Usuário</label>
<input type="text" className="form-control" placeholder="Deixe em branco para não alterar" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
</div>
<div className="form-group">
<label>Novo E-mail de Acesso</label>
<input type="email" className="form-control" placeholder="Deixe em branco para não alterar" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
</div>
<div className="form-group">
<label>Nova Senha</label>
<input type="password" className="form-control" placeholder="Deixe em branco para não alterar" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,118 @@
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
export default function Sidebar({ version, onNewPatient, onUpload, onSettings, onUsers, onSync, onPlugins }) {
const { user, isAdmin, logout } = useAuth();
const { connected } = useSocket();
const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<aside className="sidebar">
<div className="sidebar-header">
<div className="logo-icon">
<i className="fa-solid fa-tooth" />
</div>
<h2>
RF Dental{' '}
<span style={{ fontSize: '10px', opacity: 0.5, fontWeight: 500, marginLeft: 4, verticalAlign: 'middle' }}>
{version ? `v${version}` : ''}
</span>
</h2>
</div>
<nav className="sidebar-nav">
<div className="nav-section-title">Menu Principal</div>
<NavLink
to="/"
end
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<i className="fa-solid fa-images" /> Galeria
</NavLink>
<button className="nav-item" onClick={onNewPatient}>
<i className="fa-solid fa-user-plus" /> Novo Paciente
</button>
<button className="nav-item" onClick={() => onUpload?.()}>
<i className="fa-solid fa-upload" /> Enviar Imagens
</button>
<button className="nav-item" onClick={onSettings}>
<i className="fa-solid fa-user-gear" /> Minha Conta
</button>
{isAdmin && (
<>
<div className="nav-section-title">Área Administrativa</div>
<NavLink
to="/clients"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<i className="fa-solid fa-network-wired" /> Conexões
{connected && (
<span style={{ marginLeft: 'auto', width: 8, height: 8, borderRadius: '50%', background: '#10b981', display: 'inline-block', flexShrink: 0 }} />
)}
</NavLink>
<NavLink
to="/admin-clinics"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<i className="fa-solid fa-hospital" /> Clínicas e Acessos
</NavLink>
<button className="nav-item" onClick={onUsers}>
<i className="fa-solid fa-users" /> Gerenciar Usuários
</button>
<button className="nav-item" onClick={() => onSync?.('devices')}>
<i className="fa-solid fa-rotate" /> Dispositivos / Sync
</button>
<NavLink
to="/download"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
style={{ color: '#0284c7' }}
>
<i className="fa-solid fa-download" /> Baixar Cliente (.exe)
</NavLink>
<button className="nav-item" onClick={onPlugins}>
<i className="fa-solid fa-plug" /> Plugins / Wasabi
</button>
<NavLink
to="/reset"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
style={{ color: '#dc2626' }}
>
<i className="fa-solid fa-triangle-exclamation" /> Zerar Sistema
</NavLink>
</>
)}
</nav>
<div className="sidebar-footer">
{user && (
<div style={{ textAlign: 'center', fontSize: '11px', color: 'var(--text-secondary)', opacity: 0.6, marginBottom: 6 }}>
{user.username}
{isAdmin && ' · Admin'}
</div>
)}
<button className="logout-btn" onClick={handleLogout}>
<i className="fa-solid fa-arrow-right-from-bracket" /> Sair
</button>
</div>
</aside>
);
}
@@ -0,0 +1,286 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
export default function SyncModal({ isOpen, onClose, defaultTab = 'devices' }) {
const { showToast } = useToast();
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState(isAdmin ? defaultTab : 'upload');
const [devices, setDevices] = useState([]);
const [loadingDevices, setLoadingDevices] = useState(false);
const [patients, setPatients] = useState([]);
const [selectedPatient, setSelectedPatient] = useState('');
const [newPatientMode, setNewPatientMode] = useState(false);
const [newFirstName, setNewFirstName] = useState('');
const [newLastName, setNewLastName] = useState('');
const [newDoctor, setNewDoctor] = useState('');
const [remark, setRemark] = useState('');
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef();
const [triggering, setTriggering] = useState(false);
useEffect(() => {
if (!isOpen) return;
const tab = isAdmin ? defaultTab : 'upload';
setActiveTab(tab);
resetUploadForm();
loadPatients();
if (tab === 'devices') loadDevices();
}, [isOpen, defaultTab, isAdmin]);
const loadDevices = async () => {
setLoadingDevices(true);
try {
const { data } = await api.get('/socket/status');
setDevices(data.identified || []);
} catch { setDevices([]); }
finally { setLoadingDevices(false); }
};
const loadPatients = async () => {
try {
const { data } = await api.get('/images/patients?limit=200');
setPatients(data || []);
} catch {}
};
const resetUploadForm = () => {
setSelectedPatient('');
setNewPatientMode(false);
setNewFirstName(''); setNewLastName(''); setNewDoctor('');
setRemark(''); setFiles([]); setUploadProgress(0);
};
const handleFiles = (fileList) => {
const valid = Array.from(fileList).filter((f) =>
['image/jpeg', 'image/jpg', 'image/png'].includes(f.type)
);
if (valid.length !== fileList.length) showToast('Apenas JPG e PNG são aceitos.', 'warning');
setFiles(valid);
};
const handleDrop = (e) => {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
};
const handleUploadSubmit = async (e) => {
e.preventDefault();
if (!selectedPatient && !newPatientMode) { showToast('Selecione ou crie um paciente.', 'error'); return; }
if (newPatientMode && (!newFirstName || !newLastName)) { showToast('Nome e sobrenome são obrigatórios.', 'error'); return; }
if (files.length === 0) { showToast('Selecione pelo menos uma imagem.', 'error'); return; }
setUploading(true);
setUploadProgress(0);
try {
let patientName = selectedPatient;
// Criar novo paciente se necessário
if (newPatientMode) {
const fullName = `${newFirstName.trim()} ${newLastName.trim()}`;
await api.post('/patients', {
firstName: newFirstName.trim(),
lastName: newLastName.trim(),
doctor: newDoctor.trim()
});
patientName = fullName;
}
// Upload de cada arquivo
for (let i = 0; i < files.length; i++) {
const formData = new FormData();
formData.append('image', files[i]);
formData.append('patient_name', patientName);
formData.append('remark', remark);
formData.append('client_name', 'Upload Web');
await api.post('/images/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setUploadProgress(Math.round(((i + 1) / files.length) * 100));
}
showToast(`${files.length} imagem(ns) enviada(s)!`, 'success');
resetUploadForm();
onClose();
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao enviar imagens.', 'error');
} finally {
setUploading(false);
}
};
const triggerSync = async () => {
setTriggering(true);
try {
const { data } = await api.post('/system/trigger-sync');
if (data.devicesTriggered > 0) {
showToast(`✅ Sinal enviado para ${data.devicesTriggered} dispositivo(s)!`, 'success');
} else {
showToast('⚠️ Nenhum dispositivo Windows conectado no momento.', 'warning');
}
} catch { showToast('Erro ao acionar sincronização.', 'error'); }
finally { setTriggering(false); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="🔄 Sincronização e Envio de Imagens">
<div className="sync-tabs">
{isAdmin && (
<button
className={`sync-tab-btn ${activeTab === 'devices' ? 'active' : ''}`}
onClick={() => { setActiveTab('devices'); loadDevices(); }}
>💻 Dispositivos Conectados</button>
)}
<button
className={`sync-tab-btn ${activeTab === 'upload' ? 'active' : ''}`}
onClick={() => setActiveTab('upload')}
>📤 Envio Manual de Imagens</button>
</div>
{/* Tab: Dispositivos */}
{activeTab === 'devices' && (
<div>
<div style={{ background: '#f8f9fa', padding: 15, borderRadius: 8, border: '1px solid #eee', marginBottom: 20, fontSize: '0.95rem', color: '#555', lineHeight: 1.5 }}>
Os computadores clientes com Windows instalados e ativos enviam imagens em tempo real. Você pode solicitar que eles façam uma sincronização manual forçada.
</div>
{loadingDevices ? (
<div className="loading" style={{ padding: 30 }}><div className="spinner" style={{ width: 30, height: 30 }} /><p>Buscando dispositivos...</p></div>
) : devices.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px 20px', color: '#666' }}>
<span style={{ fontSize: '2.5rem', display: 'block', marginBottom: 10 }}>📭</span>
<p>Nenhum dispositivo Windows conectado no momento.</p>
</div>
) : (
<div className="sync-device-list">
{devices.map((d) => (
<div key={d.socketId} className="sync-device-item">
<div>
<div className="sync-device-name">{d.name}</div>
<div className="sync-device-status"> Online</div>
</div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{d.socketId?.slice(0, 8)}...</span>
</div>
))}
</div>
)}
<div style={{ marginTop: 25, borderTop: '1px solid #eee', paddingTop: 15, display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-secondary" onClick={onClose}>Fechar</button>
<button className="btn btn-primary" onClick={triggerSync} disabled={triggering}>
<span>🔄</span> {triggering ? 'Enviando...' : 'Enviar Sinal de Sincronização'}
</button>
</div>
</div>
)}
{/* Tab: Upload Manual */}
{activeTab === 'upload' && (
<form onSubmit={handleUploadSubmit}>
<div className="form-group">
<label>Paciente *</label>
<div style={{ display: 'flex', gap: 10 }}>
<select
required={!newPatientMode}
value={selectedPatient}
onChange={(e) => {
setSelectedPatient(e.target.value);
setNewPatientMode(e.target.value === 'NEW_PATIENT');
}}
style={{ flex: 1 }}
>
<option value="">-- Selecione o Paciente --</option>
<option value="NEW_PATIENT"> Novo Paciente...</option>
{patients.map((p) => (
<option key={p.patient_name} value={p.patient_name}>{p.patient_name}</option>
))}
</select>
<button type="button" className="btn btn-secondary" onClick={loadPatients} style={{ width: 44, padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>🔄</button>
</div>
</div>
{newPatientMode && (
<div style={{ background: '#f8f9fa', padding: 15, borderRadius: 8, border: '1px solid #eee', marginBottom: 18 }}>
<h4 style={{ margin: '0 0 12px', fontSize: '0.95rem' }}>Dados do Novo Paciente</h4>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '0.82rem', fontWeight: 600, display: 'block', marginBottom: 5 }}>Nome *</label>
<input type="text" required value={newFirstName} onChange={(e) => setNewFirstName(e.target.value)} placeholder="Ex: Maria" style={{ width: '100%', padding: '8px 12px', border: '1px solid #ccc', borderRadius: 4 }} />
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '0.82rem', fontWeight: 600, display: 'block', marginBottom: 5 }}>Sobrenome *</label>
<input type="text" required value={newLastName} onChange={(e) => setNewLastName(e.target.value)} placeholder="Ex: Oliveira" style={{ width: '100%', padding: '8px 12px', border: '1px solid #ccc', borderRadius: 4 }} />
</div>
</div>
<div>
<label style={{ fontSize: '0.82rem', fontWeight: 600, display: 'block', marginBottom: 5 }}>Dentista Responsável</label>
<input type="text" value={newDoctor} onChange={(e) => setNewDoctor(e.target.value)} placeholder="Nome do dentista (opcional)" style={{ width: '100%', padding: '8px 12px', border: '1px solid #ccc', borderRadius: 4 }} />
</div>
</div>
)}
<div className="form-group">
<label>Observações / Detalhes (Opcional)</label>
<textarea className="form-control" rows={2} value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="Ex: Raio-X Panorâmico" />
</div>
{/* Drag & Drop */}
<div
className={`drag-drop-zone${dragging ? ' dragover' : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<div className="drag-drop-icon">📸</div>
<div className="drag-drop-text">Arraste as imagens aqui ou clique para selecionar</div>
<div className="drag-drop-subtext">Suporta JPG, JPEG e PNG (máx. 10MB por imagem)</div>
<input
ref={fileInputRef} type="file" multiple accept="image/png,image/jpeg,image/jpg"
style={{ display: 'none' }} onChange={(e) => handleFiles(e.target.files)}
/>
</div>
{files.length > 0 && (
<div className="upload-previews">
{files.map((f, i) => (
<div key={i} className="upload-preview-item">
<img src={URL.createObjectURL(f)} alt={f.name} />
<button type="button" className="upload-preview-remove" onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}>×</button>
</div>
))}
</div>
)}
{uploading && (
<div className="upload-progress-container">
<div className="progress-bar-label">
<span>Enviando imagens...</span>
<span>{uploadProgress}%</span>
</div>
<div className="progress-bar-track">
<div className="progress-bar-fill" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
<div className="form-actions" style={{ borderTop: '1px solid #eee', paddingTop: 15 }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={uploading || files.length === 0}>
{uploading ? 'Enviando...' : 'Enviar Imagens'}
</button>
</div>
</form>
)}
</Modal>
);
}
@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
function formatDate(dateString) {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
export default function TransformModal({ isOpen, onClose, image, onSaved }) {
const { showToast } = useToast();
const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
const [showInfo, setShowInfo] = useState(false);
const [fineTuneAngle, setFineTuneAngle] = useState(0);
const [remark, setRemark] = useState('');
const [saving, setSaving] = useState(false);
const [originalUrl, setOriginalUrl] = useState('');
const [gtos, setGtos] = useState([]);
const [gtoNumber, setGtoNumber] = useState('');
const [gtoDesc, setGtoDesc] = useState('');
const [loadingGtos, setLoadingGtos] = useState(false);
const [resetting, setResetting] = useState(false);
// Quando a imagem muda, reinicia estado
useEffect(() => {
if (!image) return;
setFineTuneAngle(0);
setRemark('');
setShowInfo(false);
setActiveView('transform');
// Carrega URL da imagem original (mãe) se existir
if (image.original_image_id) {
api.get(`/images/${image.original_image_id}/file-url`)
.then(({ data }) => setOriginalUrl(data.url || imageUrl))
.catch(() => setOriginalUrl(imageUrl));
} else {
setOriginalUrl(imageUrl);
}
// Carrega GTOs do paciente
loadGtos(image.patient_name);
}, [image]);
if (!image) return null;
const imageUrl = image.filename?.startsWith('http')
? image.filename
: `/uploads/${image.filename}`;
const loadGtos = async (patientName) => {
setLoadingGtos(true);
try {
const { data } = await api.get(`/gtos?patient=${encodeURIComponent(patientName)}`);
setGtos(data || []);
} catch {
setGtos([]);
} finally {
setLoadingGtos(false);
}
};
const adjustAngle = (delta) => {
setFineTuneAngle((prev) => prev + delta);
};
const resetAngle = () => setFineTuneAngle(0);
const saveTransform = async () => {
if (fineTuneAngle === 0) {
showToast('Nenhuma edição aplicada.', 'error');
return;
}
setSaving(true);
try {
await api.post(`/images/${image.id}/transform`, {
rotation: fineTuneAngle,
flipH: false,
flipV: false,
remark
});
showToast('Orientação salva!', 'success');
onClose();
onSaved?.();
} catch {
showToast('Erro ao salvar orientação', 'error');
} finally {
setSaving(false);
}
};
const resetToOriginal = async () => {
if (!confirm('Restaurar esta imagem para o estado original? Rotações serão descartadas.')) return;
setResetting(true);
try {
await api.post(`/images/${image.id}/transform`, {
rotation: 0, flipH: false, flipV: false, remark: ''
});
showToast('Imagem restaurada para o original!', 'success');
onClose();
onSaved?.();
} catch {
showToast('Erro ao restaurar a imagem.', 'error');
} finally {
setResetting(false);
}
};
const createGto = async () => {
if (!gtoNumber.trim()) { showToast('O Número da GTO é obrigatório.', 'error'); return; }
try {
await api.post('/gtos', {
gto_number: gtoNumber.trim(),
description: gtoDesc.trim(),
patient_name: image.patient_name
});
showToast('GTO criada com sucesso!', 'success');
setGtoNumber('');
setGtoDesc('');
loadGtos(image.patient_name);
} catch {
showToast('Erro ao criar GTO.', 'error');
}
};
const linkToGto = async (gtoId) => {
try {
await api.post(`/gtos/${gtoId}/images`, { image_id: image.id });
showToast('Imagem vinculada à GTO!', 'success');
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao vincular', 'error');
}
};
const headerExtras = (
<div className="modal-actions-group">
<button
className={`btn btn-small ${activeView === 'transform' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveView('transform')}
title="Ajustar Imagem"
>🔄</button>
<button
className={`btn btn-small ${activeView === 'gto' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveView('gto')}
title="Gerenciar GTOs"
>📋</button>
<button
className={`btn btn-small ${showInfo ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowInfo(!showInfo)}
title="Informações"
></button>
{image.original_image_id && (
<button
className="btn btn-small btn-danger"
onClick={resetToOriginal}
disabled={resetting}
title="Resetar para Original"
></button>
)}
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`${image.patient_name || 'Desconhecido'}${formatDate(image.created_at)}`}
large
headerExtras={headerExtras}
>
{/* Info extra */}
{showInfo && (
<div className="extra-info-panel">
<div style={{ marginBottom: 5 }}><strong>👨 Dentista:</strong> {image.doctor || 'Não informado'}</div>
<div><strong>📝 Obs:</strong> {image.remark || 'Nenhuma observação'}</div>
</div>
)}
{/* View: Transformação */}
{activeView === 'transform' && (
<div className="transform-view-wrapper">
<div className="new-transform-layout">
{/* Original */}
<div className="transform-panel panel-original">
<h3 className="panel-title">Original</h3>
<div className="img-container">
<img src={originalUrl || imageUrl} alt="Original" />
</div>
</div>
{/* Preview (com rotação aplicada) */}
<div className="transform-panel panel-preview">
<h3 className="panel-title">Preview</h3>
<div className="img-container">
<img
src={imageUrl}
alt="Preview"
style={{ transform: `rotate(${fineTuneAngle}deg)` }}
/>
</div>
</div>
{/* Controles de rotação */}
<div className="transform-sidebar">
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', width: '100%' }}>
{[
{ label: '-5°', delta: -5, icon: '↺' },
{ label: '-1°', delta: -1, icon: '↺' },
].map(({ label, delta, icon }) => (
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
<span className="icon">{icon}</span>
<span className="label">{label}</span>
</button>
))}
<div className="fine-tune-display" style={{ margin: '8px 0' }}>
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
</div>
{[
{ label: '+1°', delta: 1, icon: '↻' },
{ label: '+5°', delta: 5, icon: '↻' },
].map(({ label, delta, icon }) => (
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
<span className="icon">{icon}</span>
<span className="label">{label}</span>
</button>
))}
{fineTuneAngle !== 0 && (
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: 12, width: '100%' }}>
Reset
</button>
)}
</div>
</div>
</div>
{/* Área de ação */}
<div className="transform-action-area">
<div className="form-group">
<label>Nova Observação / Detalhes (Opcional):</label>
<textarea
className="form-control"
rows={2}
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="Ex: Raio-X Dente 45..."
/>
</div>
<div className="form-actions-inline">
<button className="btn btn-secondary" onClick={onClose}>Cancelar</button>
<button
className="btn btn-primary"
onClick={saveTransform}
disabled={saving}
>
{saving ? 'Salvando...' : 'Salvar Nova Imagem'}
</button>
</div>
</div>
</div>
)}
{/* View: GTOs */}
{activeView === 'gto' && (
<div>
{/* Criar GTO */}
<div className="gto-create-section">
<h3 className="section-title">Cadastrar Nova GTO</h3>
<div className="form-actions-inline" style={{ marginTop: 12 }}>
<input
type="text" className="form-control"
placeholder="Número/ID da GTO"
value={gtoNumber}
onChange={(e) => setGtoNumber(e.target.value)}
style={{ flex: 1 }}
/>
<input
type="text" className="form-control"
placeholder="Descrição (Opcional)"
value={gtoDesc}
onChange={(e) => setGtoDesc(e.target.value)}
style={{ flex: 2 }}
/>
<button className="btn btn-primary" onClick={createGto}>Cadastrar</button>
</div>
</div>
{/* Lista de GTOs */}
<div className="gto-list-container">
{loadingGtos ? (
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
) : gtos.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Nenhuma GTO cadastrada para este paciente.</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{gtos.map((gto, idx) => (
<li key={gto.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 15, background: idx % 2 === 0 ? '#fff' : '#f9f9f9', borderBottom: '1px solid #eee' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '1.05rem' }}>{gto.gto_number}</div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{gto.description || 'Sem descrição'}</div>
<div style={{ color: '#999', fontSize: '0.8rem', marginTop: 4 }}>📅 {formatDate(gto.created_at)}</div>
</div>
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)}>
Vincular
</button>
</li>
))}
</ul>
)}
</div>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
export default function UserManagementModal({ isOpen, onClose }) {
const { showToast } = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [creating, setCreating] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const { data } = await api.get('/auth/users');
setUsers(data.users || []);
} catch { showToast('Erro ao carregar usuários.', 'error'); }
finally { setLoading(false); }
};
useEffect(() => {
if (isOpen) {
loadUsers();
setUsername(''); setEmail(''); setPassword(''); setIsAdmin(false);
}
}, [isOpen]);
const createUser = async (e) => {
e.preventDefault();
if (!username || !email || !password) { showToast('Todos os campos obrigatórios.', 'error'); return; }
setCreating(true);
try {
await api.post('/auth/create-user', { username, email, password, is_admin: isAdmin });
showToast('Usuário cadastrado com sucesso!', 'success');
setUsername(''); setEmail(''); setPassword(''); setIsAdmin(false);
loadUsers();
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao cadastrar usuário.', 'error');
} finally { setCreating(false); }
};
const deleteUser = async (userId) => {
if (!confirm('Tem certeza que deseja excluir este usuário permanentemente?')) return;
try {
await api.delete(`/auth/users/${userId}`);
showToast('Usuário excluído com sucesso!', 'success');
loadUsers();
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao excluir usuário.', 'error');
}
};
// Decodifica o token atual para não mostrar "Excluir" para si mesmo
let currentUserId = null;
try {
const tk = localStorage.getItem('auth_token');
if (tk) currentUserId = JSON.parse(atob(tk.split('.')[1])).id;
} catch {}
return (
<Modal isOpen={isOpen} onClose={onClose} title="👥 Gerenciamento de Usuários">
<div style={{ display: 'flex', gap: 24, flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Formulário criar usuário */}
<div style={{ flex: 1, minWidth: 260, background: 'rgba(0,0,0,0.02)', padding: 20, borderRadius: 10, border: '1px solid var(--border-color)' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '1.05rem', fontWeight: 700, color: 'var(--text-primary)' }}>Cadastrar Novo Usuário</h3>
<form onSubmit={createUser}>
<div className="form-group">
<label>NOME DE USUÁRIO *</label>
<input type="text" required placeholder="Ex: dentista.ana" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="form-group">
<label>E-MAIL *</label>
<input type="email" required placeholder="ana@consultorio.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="form-group">
<label>SENHA *</label>
<input type="password" required placeholder="Senha de acesso" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" id="new_is_admin" checked={isAdmin} onChange={(e) => setIsAdmin(e.target.checked)} style={{ width: 16, height: 16 }} />
<label htmlFor="new_is_admin" style={{ cursor: 'pointer', margin: 0 }}>Permissão de Administrador</label>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: 8 }} disabled={creating}>
<i className="fa-solid fa-user-plus" /> {creating ? 'Cadastrando...' : 'Cadastrar Usuário'}
</button>
</form>
</div>
{/* Lista de usuários */}
<div style={{ flex: 2, minWidth: 300 }}>
<h3 style={{ margin: '0 0 16px', fontSize: '1.05rem', fontWeight: 700 }}>Usuários Cadastrados</h3>
<div style={{ border: '1px solid var(--border-color)', borderRadius: 10, background: 'var(--bg-surface)', overflow: 'auto' }}>
<table className="clinics-table">
<thead>
<tr>
<th>Usuário</th>
<th>E-mail</th>
<th>Função</th>
<th style={{ textAlign: 'center' }}>Ações</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 20, color: '#a0aec0' }}>Carregando...</td></tr>
) : users.map((u) => (
<tr key={u.id}>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td style={{ color: 'var(--text-secondary)' }}>{u.email}</td>
<td style={{ color: 'var(--text-secondary)' }}>{u.is_admin ? '👑 Admin' : '🩺 Dentista'}</td>
<td style={{ textAlign: 'center' }}>
{parseInt(u.id) === parseInt(currentUserId)
? <span style={{ color: '#a0aec0', fontSize: '0.85rem', fontStyle: 'italic' }}>Você</span>
: <button className="btn btn-danger btn-small" onClick={() => deleteUser(u.id)}>Excluir</button>
}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../api/client';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(() => localStorage.getItem('auth_token'));
const isAdmin = !!user?.is_admin;
const verifyToken = useCallback(async (tk) => {
if (!tk) { setLoading(false); return false; }
try {
const { data } = await api.get('/auth/verify', {
headers: { Authorization: `Bearer ${tk}` }
});
if (data.valid) {
setUser(data.user);
localStorage.setItem('is_admin', data.user.is_admin ? 'true' : 'false');
localStorage.setItem('username', data.user.username || '');
localStorage.setItem('email', data.user.email || '');
return true;
}
return false;
} catch {
return false;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
verifyToken(token);
}, [token, verifyToken]);
const login = async (username, password) => {
const { data } = await api.post('/auth/login', { username, password });
const { token: newToken, user: userData } = data;
localStorage.setItem('auth_token', newToken);
localStorage.setItem('is_admin', userData.is_admin ? 'true' : 'false');
localStorage.setItem('username', userData.username || '');
localStorage.setItem('email', userData.email || '');
setToken(newToken);
setUser(userData);
return userData;
};
const logout = () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('is_admin');
localStorage.removeItem('username');
localStorage.removeItem('email');
setToken(null);
setUser(null);
};
const updateUser = (updates) => {
setUser((prev) => ({ ...prev, ...updates }));
if (updates.username) localStorage.setItem('username', updates.username);
if (updates.email) localStorage.setItem('email', updates.email);
};
return (
<AuthContext.Provider value={{ user, token, isAdmin, loading, login, logout, updateUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
@@ -0,0 +1,87 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { useAuth } from './AuthContext';
const SocketContext = createContext(null);
export function SocketProvider({ children }) {
const { token } = useAuth();
const socketRef = useRef(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!token) {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setConnected(false);
}
return;
}
const socket = io('/', {
auth: { token },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 2000,
});
socket.on('connect', () => {
console.log('[SOCKET] Conectado:', socket.id);
setConnected(true);
});
socket.on('disconnect', (reason) => {
console.log('[SOCKET] Desconectado:', reason);
setConnected(false);
});
socket.on('connect_error', (err) => {
console.error('[SOCKET] Erro de conexão:', err.message);
});
// Responde ao ping de teste do servidor
socket.on('test-connection', (data) => {
socket.emit('test-connection-ack', {
testId: data.testId,
latency: Date.now() - data.timestamp,
timestamp: Date.now()
});
});
socketRef.current = socket;
return () => {
socket.disconnect();
socketRef.current = null;
setConnected(false);
};
}, [token]);
const on = (event, handler) => {
socketRef.current?.on(event, handler);
};
const off = (event, handler) => {
socketRef.current?.off(event, handler);
};
const emit = (event, data) => {
if (socketRef.current?.connected) {
socketRef.current.emit(event, data);
}
};
return (
<SocketContext.Provider value={{ socket: socketRef.current, connected, on, off, emit }}>
{children}
</SocketContext.Provider>
);
}
export function useSocket() {
const ctx = useContext(SocketContext);
if (!ctx) throw new Error('useSocket must be used within SocketProvider');
return ctx;
}
@@ -0,0 +1,34 @@
import { createContext, useContext, useState, useCallback } from 'react';
const ToastContext = createContext(null);
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="toast-container">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.type}`}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
+600
View File
@@ -0,0 +1,600 @@
/* ================================================================
DENTAL IMAGE MANAGER - PREMIUM STYLES
Portado do style.css para o app React
================================================================ */
:root {
--primary-color: #4f46e5;
--primary-light: #6366f1;
--primary-dark: #4338ca;
--secondary-color: #ec4899;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--dark-color: #0f172a;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--bg-color: #f8fafc;
--bg-surface: #ffffff;
--border-color: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-blur: blur(12px);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
--sidebar-width: 260px;
--font-heading: 'Outfit', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-color);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
#root { height: 100vh; }
/* ================================================================
APP LAYOUT
================================================================ */
.app-container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
}
/* ================================================================
SIDEBAR
================================================================ */
.sidebar {
width: var(--sidebar-width);
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-right: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
padding: 24px 0;
z-index: 100;
box-shadow: var(--shadow-md);
flex-shrink: 0;
transition: all 0.3s ease;
}
.sidebar-header {
padding: 0 20px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.logo-icon {
width: 40px; height: 40px;
background: linear-gradient(135deg, #4cc9f0, var(--primary-color));
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; color: white;
}
.sidebar-header h2 {
font-family: var(--font-heading);
font-size: 22px; font-weight: 600;
letter-spacing: 0.5px;
color: var(--dark-color);
}
.sidebar-nav {
display: flex; flex-direction: column;
gap: 2px; padding: 0 12px;
flex: 1; overflow-y: auto;
}
.nav-item {
padding: 8px 12px; border-radius: 8px;
color: var(--dark-color); opacity: 0.7;
text-decoration: none; font-weight: 500;
font-size: 0.92rem;
display: flex; align-items: center; gap: 10px;
transition: all 0.3s ease;
cursor: pointer;
background: none; border: none; width: 100%; text-align: left;
}
.nav-item i { font-size: 18px; width: 20px; text-align: center; }
.nav-item:hover {
background: var(--bg-surface);
color: var(--primary-color);
opacity: 1;
transform: translateX(4px);
box-shadow: var(--shadow-sm);
}
.nav-item.active {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white !important;
opacity: 1;
box-shadow: var(--shadow);
font-weight: 600;
}
.nav-section-title {
font-size: 10px; text-transform: uppercase;
letter-spacing: 1.2px; font-weight: 700;
color: var(--text-secondary);
padding: 12px 16px 4px; opacity: 0.6;
}
.sidebar-footer {
border-top: 1px solid var(--border-color);
margin-top: auto; padding: 6px 8px; flex-shrink: 0;
}
.logout-btn {
width: 100%; padding: 6px 8px;
background: transparent; border: 1px solid var(--glass-border);
color: #a0aec0; border-radius: 6px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
gap: 6px; font-family: 'Inter', sans-serif;
font-weight: 500; font-size: 0.80rem;
transition: all 0.3s; line-height: 1;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger-color);
border-color: var(--danger-color);
}
/* ================================================================
MAIN CONTENT
================================================================ */
.main-content {
flex: 1; display: flex; flex-direction: column;
overflow: hidden; position: relative;
}
.header {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: 1px solid var(--glass-border);
padding: 10px 24px;
display: flex; justify-content: space-between; align-items: center;
z-index: 90; box-shadow: var(--shadow-sm); flex-shrink: 0;
}
.header-content { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 16px; }
.header-left { display: flex; align-items: center; gap: 16px; }
.header h1 { font-size: 1.5rem; font-weight: 700; color: var(--dark-color); letter-spacing: -0.5px; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.search-input {
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 0.95rem; font-family: inherit;
background: var(--bg-surface); color: var(--text-primary);
min-width: 220px; box-shadow: var(--shadow-sm);
transition: all 0.2s ease; outline: none;
}
.search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
.client-filter {
padding: 10px 16px; border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-surface); color: var(--text-primary);
font-size: 0.95rem; font-weight: 500; outline: none;
min-width: 180px; cursor: pointer; height: 42px;
}
.content-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px; }
/* Patient Header */
.patient-header { padding: 8px 24px; background: var(--bg-color); border-bottom: 1px solid var(--border-color); }
.patient-header-name { font-size: 1.2rem; font-weight: 700; color: var(--dark-color); }
.patient-header-meta { font-size: 0.9rem; color: var(--text-secondary); }
/* ================================================================
BUTTONS
================================================================ */
.btn {
padding: 10px 20px; border: none;
border-radius: var(--radius-full);
font-size: 0.95rem; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap; display: inline-flex;
align-items: center; justify-content: center;
gap: 8px; box-shadow: var(--shadow-sm);
position: relative; overflow: hidden;
}
.btn:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.btn-primary { background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); color: white; }
.btn-secondary { background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: #f1f5f9; border-color: #cbd5e1; }
.btn-success { background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); color: white; }
.btn-danger { background: linear-gradient(135deg, var(--danger-color) 0%, #dc2626 100%); color: white; }
.btn-back { background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-color); padding: 8px 16px; }
.btn-small { padding: 6px 12px; font-size: 0.85rem; }
.btn-block { width: 100%; }
/* ================================================================
GRID & CARDS
================================================================ */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.image-card, .patient-card {
background: var(--bg-surface);
border-radius: var(--radius-lg);
overflow: hidden; box-shadow: var(--shadow);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer; position: relative;
border: 1px solid var(--glass-border);
display: flex; flex-direction: column;
}
.image-card:hover, .patient-card:hover {
transform: translateY(-6px);
box-shadow: var(--shadow-lg);
border-color: var(--primary-light);
}
.image-card.disabled { opacity: 0.5; filter: grayscale(0.5); }
.image-preview {
width: 100%; height: 220px;
background: #e2e8f0; position: relative;
overflow: hidden;
}
.image-preview .preview-img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.preview-img-placeholder {
position: absolute; inset: 0;
width: 100%; height: 100%;
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
display: flex; align-items: center; justify-content: center;
color: #94a3b8; font-size: 40px;
}
.image-info { padding: 20px; background: var(--bg-surface); position: relative; z-index: 2; }
.image-patient-name { font-weight: 700; font-size: 1.1rem; color: var(--dark-color); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-meta { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-secondary); font-weight: 500; }
.image-doctor-remark { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color); font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
.image-guid { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-badge { position: absolute; top: 8px; left: 8px; background: rgba(239, 68, 68, 0.9); color: white; padding: 2px 8px; border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 600; }
.image-actions { display: flex; gap: 8px; margin-top: 10px; }
.patient-count-badge { position: absolute; bottom: 8px; right: 8px; background: rgba(15,23,42,0.75); color: white; padding: 2px 8px; border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 600; }
.delete-patient-btn {
position: absolute; top: 12px; right: 12px;
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(4px);
color: white; border: none; border-radius: 50%;
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; z-index: 10; transition: all 0.2s ease;
box-shadow: var(--shadow-md); font-size: 16px;
}
.delete-patient-btn:hover { transform: scale(1.15) rotate(90deg); background: var(--danger-color); }
/* ================================================================
MODALS
================================================================ */
.modal-overlay {
position: fixed; z-index: 1000;
left: 0; top: 0; width: 100%; height: 100%;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px) scale(0.95); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
.modal-content {
background: var(--bg-surface); border-radius: var(--radius-lg);
width: 90%; height: 98vh;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
}
.modal-large { width: 90%; height: 98vh; }
.modal-header {
padding: 24px 32px;
border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
background: var(--bg-surface); flex-shrink: 0;
}
.modal-header h2 { font-size: 1.4rem; font-weight: 700; color: var(--dark-color); }
.modal-close {
width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-color);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s ease;
border: none;
}
.modal-close:hover { background: #fee2e2; color: var(--danger-color); transform: rotate(90deg); }
.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
.modal-title-group { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.modal-actions-group { display: flex; gap: 8px; }
.modal-actions-group .btn { width: 44px; height: 44px; padding: 0; font-size: 1.2rem; }
/* ================================================================
FORMS
================================================================ */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; font-size: 0.95rem; color: var(--text-primary); }
.form-control,
.form-group input,
.form-group select,
.form-group textarea {
width: 100%; padding: 10px 12px;
border: 2px solid var(--border-color);
border-radius: var(--radius);
font-size: 0.95rem; font-family: inherit;
transition: all 0.2s ease;
background: var(--bg-color); color: var(--text-primary); outline: none;
}
.form-control:focus,
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--primary-color);
background: var(--bg-surface);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
}
.form-actions-inline { display: flex; gap: 12px; margin-top: 16px; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 25px; }
/* ================================================================
TRANSFORM MODAL STYLES
================================================================ */
.new-transform-layout {
display: flex; gap: 24px;
align-items: stretch; justify-content: center;
padding: 16px 0; min-height: 400px;
}
.transform-panel {
flex: 1; background: var(--bg-surface);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px;
display: flex; flex-direction: column; align-items: center;
box-shadow: var(--shadow-sm); min-width: 0; max-width: 45%;
}
.transform-panel.panel-preview {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
}
.panel-title { font-size: 1rem; font-weight: 700; color: var(--text-secondary); margin-bottom: 16px; }
.transform-panel .img-container { flex: 1; display: flex; align-items: center; justify-content: center; width: 100%; }
.transform-panel img { max-width: 100%; max-height: 350px; object-fit: contain; border-radius: var(--radius-sm); transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); }
.transform-sidebar {
width: 80px; display: flex; flex-direction: column;
align-items: center; background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
padding: 16px 8px; box-shadow: var(--shadow-sm); flex-shrink: 0;
}
.sidebar-btn {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
width: 56px; height: 56px; border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-color); color: var(--text-primary);
cursor: pointer; transition: all 0.2s ease;
padding: 0; margin: 0 auto;
}
.sidebar-btn:hover { background: var(--primary-light); border-color: var(--primary-color); color: white; transform: translateY(-2px); }
.sidebar-btn .icon { font-size: 1.2rem; margin-bottom: 2px; }
.sidebar-btn .label { font-size: 0.65rem; font-weight: 600; line-height: 1; }
.transform-view-wrapper { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
.transform-action-area { margin-top: 24px; background: #f8f9fa; padding: 24px; border-radius: var(--radius-sm); flex-shrink: 0; border: 1px solid var(--border-color); }
.extra-info-panel { font-size: 0.95rem; color: var(--text-secondary); background: #f8f9fa; padding: 16px 24px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); margin-bottom: 20px; }
.fine-tune-display { min-width: 40px; padding: 6px; font-size: 1rem; text-align: center; background: white; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-family: monospace; font-weight: 700; color: var(--primary-color); }
/* GTO */
.gto-create-section, .gto-link-section { background: #f8f9fa; padding: 20px; border-radius: var(--radius-sm); margin-bottom: 24px; border: 1px solid var(--border-color); }
.gto-link-section { display: flex; align-items: center; justify-content: space-between; }
.section-title { margin: 0; font-size: 1.1rem; color: var(--dark-color); }
.gto-list-container { border: 1px solid var(--border-color); border-radius: var(--radius-sm); overflow: hidden; }
/* ================================================================
TOAST
================================================================ */
.toast-container { position: fixed; bottom: 32px; right: 32px; z-index: 2000; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 16px 24px; border-radius: var(--radius-lg);
color: white; font-weight: 600; font-size: 1rem;
box-shadow: var(--shadow-lg);
display: flex; align-items: center; gap: 12px;
animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
max-width: 400px;
}
.toast.success { background: linear-gradient(135deg, var(--success-color), #059669); }
.toast.error { background: linear-gradient(135deg, var(--danger-color), #dc2626); }
.toast.info { background: linear-gradient(135deg, var(--info-color), #2563eb); }
.toast.warning { background: linear-gradient(135deg, var(--warning-color), #d97706); }
/* ================================================================
LOADING & STATES
================================================================ */
.loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: var(--text-secondary); font-weight: 500; }
.spinner { width: 48px; height: 48px; border: 4px solid var(--border-color); border-top: 4px solid var(--primary-color); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 16px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.empty-state { text-align: center; padding: 80px 20px; color: var(--text-secondary); }
.empty-icon { font-size: 4rem; margin-bottom: 24px; opacity: 0.5; }
/* ================================================================
SYNC TABS
================================================================ */
.sync-tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 2px solid var(--border-color); padding-bottom: 12px; }
.sync-tab-btn { padding: 10px 16px; background: transparent; border: none; border-radius: 8px; color: var(--text-secondary); font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-size: 0.95rem; }
.sync-tab-btn:hover { background: var(--bg-surface); color: var(--primary-color); }
.sync-tab-btn.active { background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); color: white; box-shadow: var(--shadow-sm); }
/* Wasabi Banner */
.wasabi-alert-banner { background-color: #fee2e2; border-bottom: 1px solid #f87171; color: #991b1b; padding: 12px 24px; display: flex; align-items: center; gap: 16px; z-index: 10000; width: 100%; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.wasabi-alert-icon { font-size: 24px; }
.wasabi-alert-content { flex-grow: 1; font-size: 14px; line-height: 1.5; }
.wasabi-alert-btn { background-color: #ef4444; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; white-space: nowrap; }
/* Drag & Drop */
.drag-drop-zone {
border: 2px dashed var(--border-color); border-radius: var(--radius-lg);
padding: 40px 20px; text-align: center; cursor: pointer;
transition: all 0.2s ease; background: var(--bg-color);
}
.drag-drop-zone:hover, .drag-drop-zone.dragover { border-color: var(--primary-color); background: rgba(79,70,229,0.05); }
.drag-drop-icon { font-size: 3rem; margin-bottom: 12px; }
.drag-drop-text { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.drag-drop-subtext { font-size: 0.85rem; color: var(--text-muted); }
.upload-previews { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.upload-preview-item { position: relative; }
.upload-preview-item img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; border: 2px solid var(--border-color); }
.upload-preview-remove { position: absolute; top: -6px; right: -6px; background: var(--danger-color); color: white; border: none; border-radius: 50%; width: 20px; height: 20px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
.upload-progress-container { margin-top: 16px; }
.progress-bar-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 0.9rem; font-weight: 600; }
.progress-bar-track { background: var(--border-color); border-radius: var(--radius-full); height: 8px; overflow: hidden; }
.progress-bar-fill { background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); height: 100%; border-radius: var(--radius-full); transition: width 0.3s ease; }
/* Sync device list */
.sync-device-list { display: flex; flex-direction: column; gap: 8px; }
.sync-device-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
.sync-device-name { font-weight: 600; color: var(--text-primary); }
.sync-device-status { font-size: 0.85rem; color: var(--success-color); }
/* Login page */
.login-page {
min-height: 100vh; display: flex;
align-items: center; justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
}
.login-card {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-lg);
padding: 40px; width: 100%; max-width: 420px;
box-shadow: 0 25px 50px rgba(0,0,0,0.5);
}
.login-logo { text-align: center; margin-bottom: 32px; }
.login-logo .logo-icon { width: 64px; height: 64px; font-size: 32px; margin: 0 auto 12px; }
.login-logo h1 { font-family: var(--font-heading); font-size: 1.8rem; font-weight: 700; color: white; }
.login-logo p { color: rgba(255,255,255,0.5); font-size: 0.9rem; margin-top: 4px; }
.login-form input {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.15);
color: white; border-radius: 10px;
padding: 12px 16px; font-size: 1rem; width: 100%;
outline: none; transition: all 0.2s; margin-bottom: 14px; font-family: inherit;
}
.login-form input::placeholder { color: rgba(255,255,255,0.3); }
.login-form input:focus { border-color: var(--primary-light); box-shadow: 0 0 0 3px rgba(99,102,241,0.3); }
.login-form label { display: block; color: rgba(255,255,255,0.7); font-size: 0.85rem; font-weight: 600; margin-bottom: 6px; }
.login-btn { width: 100%; padding: 14px; font-size: 1rem; font-weight: 700; border: none; border-radius: 10px; cursor: pointer; background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); color: white; transition: all 0.2s; margin-top: 8px; }
.login-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(79,70,229,0.4); }
.login-error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; padding: 12px 16px; border-radius: 8px; font-size: 0.9rem; margin-bottom: 16px; }
/* Install page */
.install-page {
min-height: 100vh; display: flex;
align-items: center; justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
padding: 20px;
}
.install-card { background: white; border-radius: var(--radius-lg); padding: 40px; width: 100%; max-width: 600px; box-shadow: var(--shadow-lg); }
.install-card h1 { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; color: var(--dark-color); margin-bottom: 8px; }
.install-card p.subtitle { color: var(--text-secondary); margin-bottom: 32px; }
.install-section { margin-bottom: 28px; padding-bottom: 28px; border-bottom: 1px solid var(--border-color); }
.install-section:last-of-type { border-bottom: none; }
.install-section h2 { font-size: 1.1rem; font-weight: 700; color: var(--dark-color); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
/* Admin clinics */
.clinics-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.clinics-table th { background: rgba(0,0,0,0.04); padding: 10px 14px; text-align: left; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); }
.clinics-table td { padding: 12px 14px; border-bottom: 1px solid var(--border-color); }
.clinics-table tr:last-child td { border-bottom: none; }
.clinics-table tr:hover td { background: rgba(79,70,229,0.03); }
/* Responsive */
@media (max-width: 768px) {
.app-container { flex-direction: column; }
.sidebar { width: 100%; height: auto; border-right: none; border-bottom: 1px solid var(--border-color); flex-direction: row; align-items: center; padding: 0 16px; }
.sidebar-header { padding: 16px 0; border-bottom: none; }
.sidebar-nav { flex-direction: row; overflow-x: auto; padding: 0 16px; gap: 16px; }
.nav-item { padding: 12px; }
.content-scroll { padding: 16px; }
.header { padding: 16px; flex-direction: column; gap: 16px; align-items: flex-start; }
.modal-content { width: 90%; height: 98vh; }
.new-transform-layout { flex-direction: column; align-items: center; }
.transform-panel { max-width: 100%; width: 100%; }
.transform-sidebar { width: 100%; flex-direction: row; border-radius: var(--radius-lg); justify-content: space-around; padding: 12px; }
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import Sidebar from '../components/Sidebar';
import Modal from '../components/Modal';
import { useToast } from '../contexts/ToastContext';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
import CreatePatientModal from '../components/CreatePatientModal';
export default function AdminClinicsPage() {
const { showToast } = useToast();
const [clinics, setClinics] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const [generatedToken, setGeneratedToken] = useState('');
const [form, setForm] = useState({ clinic_name: '', pc_name: '', email: '' });
const [saving, setSaving] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const loadClinics = async () => {
setLoading(true);
try {
const { data } = await api.get('/admin/clinics');
setClinics(data.clinics || []);
} catch (e) {
showToast(e.response?.data?.message || 'Erro ao carregar clínicas.', 'error');
} finally { setLoading(false); }
};
useEffect(() => { loadClinics(); }, []);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const { data } = await api.post('/admin/clinics', form);
if (data.success) {
setShowAddModal(false);
setForm({ clinic_name: '', pc_name: '', email: '' });
setGeneratedToken(data.machine_token);
setShowTokenModal(true);
loadClinics();
}
} catch (e) { showToast(e.response?.data?.message || 'Erro ao cadastrar.', 'error'); }
finally { setSaving(false); }
};
const toggleStatus = async (id, isActive) => {
try {
await api.put(`/admin/clinics/${id}/status`, { is_active: isActive });
showToast(isActive ? 'Dispositivo Ativado' : 'Dispositivo Bloqueado', 'success');
loadClinics();
} catch { showToast('Erro ao alterar status', 'error'); loadClinics(); }
};
const deleteClinic = async (id) => {
if (!confirm('Tem certeza que deseja excluir este dispositivo permanentemente? O Desktop perderá acesso instantaneamente.')) return;
try {
await api.delete(`/admin/clinics/${id}`);
showToast('Dispositivo removido', 'success');
loadClinics();
} catch { showToast('Erro ao excluir', 'error'); }
};
const total = clinics.length;
const active = clinics.filter((c) => c.is_active).length;
const blocked = total - active;
const set = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
return (
<div className="app-container">
<Sidebar
onNewPatient={() => setShowCreate(true)}
onUpload={() => setShowSync(true)}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={() => setShowSync(true)}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1>🏥 Clínicas e Acessos</h1>
</div>
<div className="header-actions">
<button className="btn btn-primary" onClick={() => setShowAddModal(true)} style={{ height: 42 }}>
<i className="fa-solid fa-plus" /> Cadastrar Clínica
</button>
</div>
</div>
</header>
<div className="content-scroll">
{/* Stats */}
<div style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
{[
{ label: 'Total de Dispositivos', value: total, color: '#4f46e5' },
{ label: 'Dispositivos Ativos', value: active, color: '#10b981' },
{ label: 'Dispositivos Bloqueados', value: blocked, color: '#ef4444' },
].map(({ label, value, color }) => (
<div key={label} style={{ flex: 1, minWidth: 140, background: 'white', borderRadius: 12, padding: '20px 24px', border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-sm)' }}>
<div style={{ fontSize: '2.2rem', fontWeight: 700, color }}>{value}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: 4 }}>{label}</div>
</div>
))}
</div>
{/* Table */}
<div style={{ background: 'white', borderRadius: 12, border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-sm)', overflow: 'auto' }}>
<table className="clinics-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>Status</th>
<th>Clínica</th>
<th>Nome do PC</th>
<th>E-mail</th>
<th>Último IP</th>
<th>Token</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 30 }}><div className="spinner" style={{ width: 30, height: 30, margin: '0 auto' }} /></td></tr>
) : clinics.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 30, color: '#a0aec0' }}>Nenhum dispositivo cadastrado.</td></tr>
) : clinics.map((c) => (
<tr key={c.id}>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px', borderRadius: 20, background: c.is_active ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)', color: c.is_active ? '#065f46' : '#991b1b', fontWeight: 700, fontSize: '0.8rem' }}>
{c.is_active ? '✓ Ativo' : '⊘ Bloqueado'}
</span>
</td>
<td><strong>{c.clinic_name}</strong></td>
<td>{c.pc_name || '—'}</td>
<td>{c.email}</td>
<td style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{c.last_ip || 'Nunca conectou'}</td>
<td>
<span
style={{ fontFamily: 'monospace', fontSize: '0.8rem', cursor: 'pointer', color: 'var(--primary-color)', userSelect: 'all' }}
title="Clique para copiar"
onClick={() => { navigator.clipboard.writeText(c.machine_token); showToast('Token copiado!', 'success'); }}
>{c.machine_token?.split('-')[0]}...</span>
</td>
<td>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<label style={{ display: 'inline-flex', cursor: 'pointer', alignItems: 'center', gap: 6, fontSize: '0.85rem' }}>
<input type="checkbox" checked={c.is_active} onChange={(e) => toggleStatus(c.id, e.target.checked)} />
{c.is_active ? 'Ativo' : 'Bloqueado'}
</label>
<button className="btn btn-danger btn-small" onClick={() => deleteClinic(c.id)}>🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
{/* Add clinic modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Cadastrar Nova Clínica / Dispositivo" large={false}>
<form onSubmit={handleSubmit}>
<div className="form-group"><label>Nome da Clínica *</label><input type="text" required value={form.clinic_name} onChange={set('clinic_name')} placeholder="Ex: Clínica Odonto" /></div>
<div className="form-group"><label>Nome do PC</label><input type="text" value={form.pc_name} onChange={set('pc_name')} placeholder="Ex: DESKTOP-RECEPCAO" /></div>
<div className="form-group"><label>E-mail de Contato *</label><input type="email" required value={form.email} onChange={set('email')} placeholder="contato@clinica.com" /></div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAddModal(false)}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Salvando...' : 'Salvar e Gerar Token'}</button>
</div>
</form>
</Modal>
{/* Token modal */}
<Modal isOpen={showTokenModal} onClose={() => setShowTokenModal(false)} title="✅ Dispositivo Cadastrado!" large={false}>
<div>
<p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>O dispositivo foi cadastrado. Copie o token abaixo e configure no cliente Windows:</p>
<div style={{ background: '#f8f9fa', padding: 16, borderRadius: 8, border: '1px solid #eee', marginBottom: 16 }}>
<code style={{ fontFamily: 'monospace', fontSize: '0.95rem', wordBreak: 'break-all', color: 'var(--primary-color)' }}>{generatedToken}</code>
</div>
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => { navigator.clipboard.writeText(generatedToken); showToast('Token copiado!', 'success'); }}>
<i className="fa-regular fa-copy" /> Copiar Token
</button>
</div>
</Modal>
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} />
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
</div>
);
}
@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import Sidebar from '../components/Sidebar';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
import CreatePatientModal from '../components/CreatePatientModal';
export default function ClientsPage() {
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const loadClients = async () => {
setLoading(true);
try {
const { data } = await api.get('/socket/status');
setClients(data.identified || []);
} catch { setClients([]); }
finally { setLoading(false); }
};
useEffect(() => { loadClients(); }, []);
return (
<div className="app-container">
<Sidebar
onNewPatient={() => setShowCreate(true)}
onUpload={() => setShowSync(true)}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={() => setShowSync(true)}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1>💻 Conexões de Clientes Windows</h1>
</div>
<div className="header-actions">
<button className="btn btn-secondary" onClick={loadClients} style={{ height: 42 }}>🔄 Atualizar</button>
</div>
</div>
</header>
<div className="content-scroll">
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div style={{ background: 'rgba(59,130,246,0.08)', padding: 16, borderRadius: 10, border: '1px solid rgba(59,130,246,0.15)', marginBottom: 24, fontSize: '0.95rem', color: '#1e40af', lineHeight: 1.6 }}>
<strong> Conexões identificadas:</strong> Clientes Windows com machine_token ou api_key que estão enviando imagens em tempo real.
</div>
{loading ? (
<div className="loading"><div className="spinner" /><p>Buscando clientes...</p></div>
) : clients.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">📭</div>
<h2>Nenhum cliente conectado</h2>
<p>Nenhum dispositivo Windows está conectado ao servidor no momento.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{clients.map((c, i) => (
<div key={c.socketId || i} style={{ background: 'white', borderRadius: 12, padding: '16px 24px', border: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', boxShadow: 'var(--shadow-sm)' }}>
<div>
<div style={{ fontWeight: 700, fontSize: '1.05rem', marginBottom: 4 }}>💻 {c.name || 'Dispositivo Desconhecido'}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<span>SO: {c.system || 'Windows'}</span>
{c.version && <span> · Versão: {c.version}</span>}
{c.socketId && <span> · ID: {c.socketId.slice(0, 12)}...</span>}
</div>
</div>
<span style={{ padding: '4px 12px', borderRadius: 20, background: 'rgba(16,185,129,0.1)', color: '#065f46', fontWeight: 700, fontSize: '0.8rem' }}>
ONLINE
</span>
</div>
))}
</div>
)}
</div>
</div>
</main>
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} />
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
</div>
);
}
@@ -0,0 +1,439 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import api from '../api/client';
import { useSocket } from '../contexts/SocketContext';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import TransformModal from '../components/TransformModal';
import CreatePatientModal from '../components/CreatePatientModal';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
import Sidebar from '../components/Sidebar';
function formatDate(d) {
if (!d) return '—';
return new Date(d).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export default function DashboardPage() {
const { isAdmin } = useAuth();
const { showToast } = useToast();
const { on, off } = useSocket();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// View state: 'patients' | 'patient-images'
const [view, setView] = useState('patients');
const [selectedPatient, setSelectedPatient] = useState(null);
// Patients list
const [patients, setPatients] = useState([]);
const [loadingPatients, setLoadingPatients] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const isFetchingRef = useRef(false);
// Patient images
const [patientImages, setPatientImages] = useState([]);
const [loadingImages, setLoadingImages] = useState(false);
// Filters
const [search, setSearch] = useState('');
const [selectedClient, setSelectedClient] = useState('');
const [showDisabled, setShowDisabled] = useState(false);
const [clientsList, setClientsList] = useState([]);
// Modals
const [transformImage, setTransformImage] = useState(null);
const [showCreatePatient, setShowCreatePatient] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [syncDefaultTab, setSyncDefaultTab] = useState('devices');
// Wasabi banner
const [wasabiError, setWasabiError] = useState(null);
// App version
const [version, setVersion] = useState('');
// ── Init ──────────────────────────────────────────────────────────
useEffect(() => {
loadClientsList();
// Check version
fetch('/version.txt').then((r) => r.text()).then((v) => setVersion(v.trim())).catch(() => {});
// Check Wasabi status
checkWasabiStatus();
}, []);
// ── URL query actions (ex: ?action=new-patient) ───────────────────
useEffect(() => {
const action = searchParams.get('action');
if (!action) return;
setTimeout(() => {
if (action === 'new-patient') setShowCreatePatient(true);
else if (action === 'settings') setShowSettings(true);
else if (action === 'plugins') setShowPlugins(true);
else if (action === 'sync-devices') { setSyncDefaultTab('devices'); setShowSync(true); }
else if (action === 'sync-upload') { setSyncDefaultTab('upload'); setShowSync(true); }
else if (action === 'users') setShowUsers(true);
navigate('/', { replace: true });
}, 300);
}, [searchParams]);
// ── Socket.IO: nova imagem em tempo real ──────────────────────────
useEffect(() => {
let debounceTimer;
const handler = () => {
showToast('Nova imagem recebida!', 'info');
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (view === 'patients') loadPatients(true);
else if (selectedPatient) loadPatientImages(selectedPatient.patient_name);
}, 1200);
};
on('new-image', handler);
on('clients-list-updated', loadClientsList);
return () => {
off('new-image', handler);
off('clients-list-updated', loadClientsList);
clearTimeout(debounceTimer);
};
}, [view, selectedPatient, on, off]);
// ── Load clients list ─────────────────────────────────────────────
const loadClientsList = useCallback(async () => {
try {
let identified = [];
if (isAdmin) {
const { data } = await api.get('/socket/status');
identified = data.identified || [];
}
let list = identified.map((c) => ({ ...c, name: c.name || 'Unknown', status: 'identified' }));
const { data: unique } = await api.get('/images/unique-clients');
(unique || []).forEach((cn) => {
if (!list.find((c) => c.name === cn)) {
list.push({ name: cn, status: 'historic' });
}
});
setClientsList(list);
} catch {}
}, [isAdmin]);
// ── Load patients ─────────────────────────────────────────────────
const loadPatients = useCallback(async (silent = false, loadMore = false) => {
if (isFetchingRef.current) return;
if (loadMore && !hasMore) return;
isFetchingRef.current = true;
const currentPage = loadMore ? page : 1;
if (!loadMore) {
setPage(1);
setHasMore(true);
if (!silent) setLoadingPatients(true);
}
try {
const clientParam = selectedClient ? `&client=${encodeURIComponent(selectedClient)}` : '';
const { data } = await api.get(
`/images/patients?disabled=${showDisabled}&search=${encodeURIComponent(search)}${clientParam}&page=${currentPage}&limit=50`
);
if (data.length < 50) setHasMore(false);
if (loadMore) {
setPatients((prev) => [...prev, ...data]);
setPage((p) => p + 1);
} else {
setPatients(data);
if (hasMore) setPage(2);
}
} catch (e) {
showToast('Erro ao carregar pacientes', 'error');
} finally {
setLoadingPatients(false);
isFetchingRef.current = false;
}
}, [search, selectedClient, showDisabled, page, hasMore, showToast]);
// Load on filter changes
useEffect(() => {
if (view === 'patients') loadPatients();
}, [search, selectedClient, showDisabled]);
// Initial load
useEffect(() => { loadPatients(); }, []);
// ── Scroll infinito ───────────────────────────────────────────────
const handleScroll = (e) => {
if (view !== 'patients') return;
const el = e.target;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
loadPatients(false, true);
}
};
// ── Load patient images ───────────────────────────────────────────
const loadPatientImages = async (patientName) => {
setLoadingImages(true);
try {
const { data } = await api.get(`/images/by-patient?name=${encodeURIComponent(patientName)}&disabled=${showDisabled}`);
setPatientImages(data || []);
} catch { showToast('Erro ao carregar imagens', 'error'); }
finally { setLoadingImages(false); }
};
const openPatient = (patient) => {
setSelectedPatient(patient);
setView('patient-images');
loadPatientImages(patient.patient_name);
};
const goBack = () => {
setView('patients');
setSelectedPatient(null);
loadPatients();
};
// ── Toggle image enabled ──────────────────────────────────────────
const toggleImageEnabled = async (imageId) => {
try {
const { data } = await api.put(`/images/${imageId}/toggle-enabled`);
showToast(data.enabled ? 'Imagem habilitada!' : 'Imagem desabilitada!', 'success');
if (selectedPatient) loadPatientImages(selectedPatient.patient_name);
} catch { showToast('Erro ao alterar status', 'error'); }
};
// ── Delete patient ────────────────────────────────────────────────
const deletePatient = async (patientName) => {
if (!confirm(`⚠️ Excluir permanentemente todas as imagens de "${patientName}"?\n\nEsta ação não pode ser desfeita.`)) return;
showToast('Excluindo imagens do paciente...', 'info');
try {
await api.delete(`/images/by-patient?name=${encodeURIComponent(patientName)}`);
showToast('✅ Todas as imagens foram excluídas!', 'success');
loadPatients();
loadClientsList();
} catch (e) { showToast(e.response?.data?.error || 'Falha ao excluir.', 'error'); }
};
// ── Wasabi status ─────────────────────────────────────────────────
const checkWasabiStatus = async () => {
try {
const { data } = await api.get('/system/wasabi-status');
if (data.enabled && data.error) setWasabiError(data.error);
else setWasabiError(null);
} catch {}
};
return (
<>
{/* Wasabi Alert Banner */}
{wasabiError && (
<div className="wasabi-alert-banner" style={{ position: 'fixed', top: 0, zIndex: 10000, left: 0, right: 0 }}>
<span className="wasabi-alert-icon"></span>
<div className="wasabi-alert-content">
<strong>Atenção:</strong> Falha de autenticação com o Wasabi S3 (<span>{wasabiError}</span>). As imagens não poderão ser salvas ou visualizadas.
</div>
<button className="wasabi-alert-btn" onClick={() => setShowPlugins(true)}>Configurar Agora</button>
</div>
)}
<div className="app-container" style={wasabiError ? { paddingTop: 52 } : {}}>
<Sidebar
version={version}
onNewPatient={() => setShowCreatePatient(true)}
onUpload={() => { setSyncDefaultTab('upload'); setShowSync(true); }}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={(tab) => { setSyncDefaultTab(tab || 'devices'); setShowSync(true); }}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
{/* Header */}
<header className="header">
<div className="header-content">
<div className="header-left">
{view === 'patient-images' && (
<button className="btn btn-back" onClick={goBack}> Voltar</button>
)}
<h1 id="mainTitle">
{view === 'patients' ? 'Galeria de Imagens' : selectedPatient?.patient_name || ''}
</h1>
</div>
{view === 'patients' && (
<div className="header-actions">
<select
className="client-filter"
value={selectedClient}
onChange={(e) => setSelectedClient(e.target.value)}
style={{ height: 42 }}
>
<option value="">📋 Todos os Clientes</option>
{clientsList.map((c) => (
<option key={c.name} value={c.name}>
{c.name === 'Upload Web' ? 'Upload Web 💻 (Imagens do Painel)' : `${c.name}${c.status === 'identified' ? ' ✅' : c.status === 'historic' ? ' 📚' : ''}`}
</option>
))}
</select>
<button
className="btn btn-secondary"
onClick={() => setShowDisabled(!showDisabled)}
style={{ height: 42, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 6 }}
>
👁 {showDisabled ? 'Ver Ativas' : 'Ver Ocultas'}
</button>
<input
type="search"
className="search-input"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ height: 42, minWidth: 200 }}
/>
<button className="btn btn-secondary" onClick={() => loadPatients()} style={{ height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 16px' }}>
🔄
</button>
</div>
)}
{view === 'patient-images' && (
<div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
{selectedPatient?.image_count || 0} imagem(ns) · {selectedPatient?.doctor || '—'}
</div>
)}
</div>
</header>
{/* Scrollable content */}
<div className="content-scroll" onScroll={handleScroll}>
{/* PATIENTS VIEW */}
{view === 'patients' && (
<>
{loadingPatients && (
<div className="loading"><div className="spinner" /><p>Carregando...</p></div>
)}
{!loadingPatients && patients.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📭</div>
<h2>Nenhum resultado encontrado</h2>
<p>As imagens aparecerão aqui quando forem enviadas pelo cliente Windows</p>
</div>
)}
{!loadingPatients && patients.length > 0 && (
<div className="images-grid">
{patients.map((p, idx) => {
const thumb = p.thumb_url || (p.thumb_filename ? `/uploads/${p.thumb_filename}` : '');
const fullName = p.patient_name || 'Sem nome';
return (
<div key={`${fullName}-${idx}`} className="image-card patient-card" onClick={() => openPatient(p)}>
<div className="image-preview">
{thumb
? <img className="preview-img" src={thumb} alt="" loading="lazy" decoding="async" style={{ background: '#e0e7ff' }} />
: <div className="preview-img-placeholder">🦷</div>
}
<button
type="button"
className="delete-patient-btn"
onClick={(e) => { e.stopPropagation(); deletePatient(fullName); }}
title={`Excluir todas as imagens de ${fullName}`}
>🗑</button>
<div className="patient-count-badge">{p.image_count || 0} imagem{(p.image_count || 0) !== 1 ? 'ns' : ''}</div>
</div>
<div className="image-info">
<div className="image-patient-name" title={fullName}>{fullName}</div>
<div className="image-meta"><span>📅 {formatDate(p.last_date)}</span></div>
<div className="image-doctor-remark">
<div title={p.doctor || '—'}>🩺 <b>Dentista:</b> {p.doctor || '—'}</div>
<div title={p.remark || '—'}>📝 <b>Obs:</b> {p.remark || '—'}</div>
</div>
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* PATIENT IMAGES VIEW */}
{view === 'patient-images' && (
<>
{loadingImages && (
<div className="loading"><div className="spinner" /><p>Carregando imagens...</p></div>
)}
{!loadingImages && patientImages.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📭</div>
<h2>Nenhuma imagem encontrada</h2>
</div>
)}
{!loadingImages && patientImages.length > 0 && (
<div className="images-grid">
{patientImages.map((image) => {
const thumbUrl = image.thumb_url || (image.thumb_filename ? `/uploads/${image.thumb_filename}` : (image.file_url || `/uploads/${image.filename}`));
return (
<div
key={image.id}
className={`image-card ${!image.enabled ? 'disabled' : ''}`}
data-id={image.id}
>
<div className="image-preview">
<img className="preview-img" src={thumbUrl} alt="" loading="lazy" decoding="async" />
</div>
<div className="image-info">
<div className="image-meta"><span>📅 {formatDate(image.created_at)}</span></div>
<div className="image-guid">{image.image_guid || image.filename}</div>
<div className="image-actions">
<button
className="btn btn-primary btn-small"
style={{ flex: 1 }}
onClick={() => setTransformImage(image)}
>🔄 Orientar</button>
<button
className="btn btn-small"
style={{
flex: 1,
background: image.enabled ? 'rgba(255,107,107,0.12)' : 'rgba(81,207,102,0.12)',
color: image.enabled ? '#c0392b' : '#27ae60',
border: `1px solid ${image.enabled ? 'rgba(255,107,107,0.3)' : 'rgba(81,207,102,0.3)'}`
}}
onClick={() => toggleImageEnabled(image.id)}
>{image.enabled ? '🚫' : '✅'}</button>
</div>
</div>
{!image.enabled && <div className="image-badge">Desabilitada</div>}
</div>
);
})}
</div>
)}
</>
)}
</div>
</main>
</div>
{/* MODALS */}
<TransformModal
isOpen={!!transformImage}
onClose={() => setTransformImage(null)}
image={transformImage}
onSaved={() => selectedPatient && loadPatientImages(selectedPatient.patient_name)}
/>
<CreatePatientModal isOpen={showCreatePatient} onClose={() => setShowCreatePatient(false)} onCreated={loadPatients} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => { setShowPlugins(false); checkWasabiStatus(); }} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncDefaultTab} />
</>
);
}
@@ -0,0 +1,85 @@
import { useState, useEffect } from 'react';
import Sidebar from '../components/Sidebar';
import SettingsModal from '../components/SettingsModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
import CreatePatientModal from '../components/CreatePatientModal';
import UserManagementModal from '../components/UserManagementModal';
export default function DownloadPage() {
const [version, setVersion] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [showCreate, setShowCreate] = useState(false);
useEffect(() => {
fetch('/updates/latest.yml')
.then((r) => r.text())
.then((t) => {
const m = t.match(/version:\s*([\d.]+)/);
if (m) setVersion(m[1]);
}).catch(() => {});
}, []);
return (
<div className="app-container">
<Sidebar
version={version}
onNewPatient={() => setShowCreate(true)}
onUpload={() => setShowSync(true)}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={() => setShowSync(true)}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1> Download do Cliente Windows</h1>
</div>
</div>
</header>
<div className="content-scroll">
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ background: 'white', borderRadius: 16, padding: 40, border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-md)', textAlign: 'center' }}>
<div style={{ fontSize: '5rem', marginBottom: 24 }}>💻</div>
<h2 style={{ marginBottom: 8, fontSize: '1.8rem' }}>RF Dental Cliente Windows</h2>
{version && <p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>Versão {version}</p>}
<p style={{ color: 'var(--text-secondary)', marginBottom: 32, lineHeight: 1.7 }}>
Instale o cliente Windows nas máquinas do consultório para enviar imagens de raio-X automaticamente para este painel em tempo real.
</p>
<a
href="/updates/RFDental-Setup.exe"
className="btn btn-primary"
style={{ padding: '16px 40px', fontSize: '1.1rem', textDecoration: 'none', display: 'inline-flex', marginBottom: 16 }}
download
>
<i className="fa-solid fa-download" /> Baixar Instalador (.exe)
</a>
<div style={{ background: '#f0f9ff', borderRadius: 10, padding: 20, marginTop: 24, textAlign: 'left' }}>
<h4 style={{ marginBottom: 12, color: '#0369a1' }}>📋 Instruções de Instalação:</h4>
<ol style={{ paddingLeft: 20, lineHeight: 2, color: 'var(--text-secondary)', fontSize: '0.95rem' }}>
<li>Baixe o instalador acima</li>
<li>Execute o arquivo <strong>RFDental-Setup.exe</strong></li>
<li>Na página <strong>Clínicas e Acessos</strong>, cadastre o dispositivo e copie o machine_token gerado</li>
<li>Cole o token na configuração do cliente Windows</li>
<li>As imagens serão enviadas automaticamente</li>
</ol>
</div>
</div>
</div>
</div>
</main>
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} />
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
</div>
);
}
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function InstallPage() {
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [installed, setInstalled] = useState(false);
const [form, setForm] = useState({
adminUsername: 'admin',
adminEmail: '',
adminPassword: ''
});
useEffect(() => {
fetch('/api/install/status')
.then((r) => r.json())
.then((data) => {
setStatus(data);
if (data.installed && data.databaseExists && data.userExists) {
setInstalled(true);
}
})
.catch(() => {});
}, []);
const set = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!form.adminUsername || !form.adminEmail || !form.adminPassword) {
setError('Preencha todos os campos do administrador!');
return;
}
if (form.adminPassword.length < 8) {
setError('A senha deve ter no mínimo 8 caracteres!');
return;
}
setLoading(true);
try {
const res = await fetch('/api/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
admin: {
username: form.adminUsername.trim(),
email: form.adminEmail.trim(),
password: form.adminPassword
}
})
});
const result = await res.json();
if (result.success) {
setSuccess('Instalação concluída com sucesso! Redirecionando para o login...');
setTimeout(() => navigate('/login'), 2000);
} else {
setError(result.error || 'Erro na instalação. Verifique os dados e tente novamente.');
}
} catch (e) {
setError('Erro ao conectar com o servidor: ' + e.message);
} finally {
setLoading(false);
}
};
return (
<div className="install-page">
<div className="install-card" style={{ maxWidth: 520 }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '3rem', marginBottom: 12 }}>🦷</div>
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '2rem', marginBottom: 8 }}>Instalação do Sistema</h1>
<p className="subtitle">Configure o usuário administrador para começar a usar</p>
</div>
{/* Status da instalação */}
{status && (
<div style={{ background: '#f8f9fa', borderRadius: 10, padding: 16, marginBottom: 24, border: '1px solid #eee' }}>
{[
{ label: 'Banco de dados', ok: status.databaseExists },
{ label: 'Tabelas criadas', ok: status.databaseExists },
{ label: 'Usuário admin', ok: status.userExists },
].map(({ label, ok }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #eee' }}>
<span>{label}</span>
<span style={{ fontWeight: 700, color: ok ? '#28a745' : '#dc3545' }}>{ok ? '✓ OK' : '✗ Pendente'}</span>
</div>
))}
</div>
)}
{error && <div style={{ background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb', padding: '12px 16px', borderRadius: 8, marginBottom: 20, fontSize: '0.9rem' }}>{error}</div>}
{success && <div style={{ background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb', padding: '12px 16px', borderRadius: 8, marginBottom: 20, fontSize: '0.9rem' }}>{success}</div>}
{installed ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '4rem', marginBottom: 16 }}></div>
<h2 style={{ marginBottom: 12 }}>Sistema Instalado!</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>O sistema está configurado e pronto para uso.</p>
<button className="btn btn-primary" style={{ width: '100%', padding: 14 }} onClick={() => navigate('/login')}>
Ir para o Login
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="install-section">
<h2>
<span style={{ background: 'var(--primary-color)', color: 'white', width: 24, height: 24, borderRadius: '50%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', fontWeight: 700, marginRight: 8 }}>1</span>
Usuário Administrador
</h2>
<div className="form-group">
<label>Nome de Usuário</label>
<input type="text" required value={form.adminUsername} onChange={set('adminUsername')} placeholder="admin" />
</div>
<div className="form-group">
<label>E-mail</label>
<input type="email" required value={form.adminEmail} onChange={set('adminEmail')} placeholder="admin@clinica.com" />
</div>
<div className="form-group">
<label>Senha (mínimo 8 caracteres)</label>
<input type="password" required value={form.adminPassword} onChange={set('adminPassword')} placeholder="Senha segura..." />
</div>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', padding: 14, fontSize: '1rem', borderRadius: 10 }} disabled={loading}>
{loading ? '⏳ Instalando...' : '▶ Iniciar Instalação'}
</button>
</form>
)}
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const from = location.state?.from?.pathname || '/';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (e) {
setError(e.response?.data?.error || 'Usuário ou senha inválidos.');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-logo">
<div className="logo-icon" style={{ margin: '0 auto 16px' }}>
<i className="fa-solid fa-tooth" style={{ fontSize: 28 }} />
</div>
<h1>RF Dental</h1>
<p>Gerenciador de Imagens Dentais</p>
</div>
{error && <div className="login-error">{error}</div>}
<form className="login-form" onSubmit={handleSubmit}>
<div>
<label>Usuário ou E-mail</label>
<input
type="text"
id="login-username"
placeholder="seu@email.com ou usuario"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
/>
</div>
<div>
<label>Senha</label>
<input
type="password"
id="login-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
className="login-btn"
disabled={loading}
style={{ marginTop: 8 }}
>
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 24, fontSize: '0.8rem', color: 'rgba(255,255,255,0.3)' }}>
Sistema de gerenciamento de raio-X dental
</p>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api/client';
import Sidebar from '../components/Sidebar';
import { useToast } from '../contexts/ToastContext';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
import CreatePatientModal from '../components/CreatePatientModal';
export default function ResetPage() {
const { showToast } = useToast();
const navigate = useNavigate();
const [resetting, setResetting] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const handleReset = async () => {
if (confirmText !== 'CONFIRMAR') { showToast('Digite CONFIRMAR para prosseguir.', 'error'); return; }
if (!confirm('⚠️ ATENÇÃO MÁXIMA: Esta ação irá APAGAR TODOS os dados do sistema (imagens, pacientes, clínicas). Esta ação é IRREVERSÍVEL. Deseja continuar?')) return;
if (!confirm('Tem ABSOLUTA certeza? Todos os dados serão perdidos permanentemente.')) return;
setResetting(true);
try {
await api.post('/system/reset-all');
showToast('✅ Sistema resetado com sucesso. Redirecionando...', 'success');
setTimeout(() => navigate('/'), 2000);
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao resetar o sistema.', 'error');
} finally { setResetting(false); }
};
return (
<div className="app-container">
<Sidebar
onNewPatient={() => setShowCreate(true)}
onUpload={() => setShowSync(true)}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={() => setShowSync(true)}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1 style={{ color: '#dc2626' }}> Zerar Sistema</h1>
</div>
</div>
</header>
<div className="content-scroll">
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ background: '#fee2e2', border: '1px solid #f87171', borderRadius: 12, padding: 28, marginBottom: 24 }}>
<h2 style={{ color: '#991b1b', marginBottom: 12 }}>🚨 Ação Irreversível</h2>
<p style={{ color: '#7f1d1d', lineHeight: 1.7 }}>
Esta ação irá apagar <strong>TODOS</strong> os dados do sistema:<br />
Todas as imagens de raio-X<br />
Todos os pacientes cadastrados<br />
Todos os registros do banco de dados<br />
Arquivos locais e no Wasabi S3<br /><br />
Esta ação <strong>não pode ser desfeita</strong>.
</p>
</div>
<div style={{ background: 'white', borderRadius: 12, padding: 28, border: '1px solid var(--border-color)', boxShadow: 'var(--shadow)' }}>
<h3 style={{ marginBottom: 16 }}>Confirme a ação</h3>
<p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>
Digite <strong>CONFIRMAR</strong> no campo abaixo para habilitar o botão de reset:
</p>
<input
type="text"
className="form-control"
placeholder="Digite: CONFIRMAR"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
style={{ marginBottom: 20, borderColor: confirmText === 'CONFIRMAR' ? 'var(--success-color)' : '' }}
/>
<button
className="btn btn-danger btn-block"
onClick={handleReset}
disabled={confirmText !== 'CONFIRMAR' || resetting}
style={{ padding: '14px 0', fontSize: '1rem' }}
>
{resetting ? '⏳ Resetando sistema...' : '🔴 Resetar Todo o Sistema Agora'}
</button>
</div>
</div>
</div>
</main>
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} />
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
</div>
);
}
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// Em dev, faz proxy da API para o backend Express
server: {
proxy: {
'/api': 'http://localhost:3000',
'/socket.io': {
target: 'http://localhost:3000',
ws: true
},
'/uploads': 'http://localhost:3000',
'/updates': 'http://localhost:3000'
}
},
build: {
// O output vai para o diretório que o Express vai servir
outDir: 'dist',
emptyOutDir: true
}
})