This commit is contained in:
@@ -8,10 +8,12 @@ RUN apk add --no-cache python3 make g++ vips-dev
|
||||
# Copiar os arquivos de lock e package
|
||||
COPY package*.json ./
|
||||
|
||||
# Instalar dependências
|
||||
# Instalar dependências do backend (produção)
|
||||
RUN npm install --production
|
||||
|
||||
# Copiar o restante do código
|
||||
# NOTA: dental-client/dist/ deve ser gerado ANTES do docker build pelo deploy-prod.sh
|
||||
# O build do React (npm run build) é feito na VPS4 antes do docker compose build
|
||||
COPY . .
|
||||
|
||||
# Expor a porta 3000 (apenas para o container)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
+2005
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 894 B |
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 já 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
|
||||
}
|
||||
})
|
||||
@@ -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 |
+14
-3
@@ -645,9 +645,8 @@ app.use('/api/admin/clinics', adminClinicsRoutes);
|
||||
// MIDDLEWARES DE ARQUIVOS ESTÁTICOS (Movidos para o fim)
|
||||
// ================================================================
|
||||
|
||||
// Serve static files e rotas estáticas
|
||||
app.use('/login', express.static('auth'));
|
||||
app.use(express.static('public'));
|
||||
// Serve o build estático do React (gerado pelo Vite em dental-client/dist/)
|
||||
app.use(express.static(path.join(__dirname, 'dental-client', 'dist')));
|
||||
// Rota de proxy para arquivos do S3 (ou locais via fallback)
|
||||
// Rota de proxy para arquivos do S3 (ou locais via fallback)
|
||||
app.get('/uploads/:filename', async (req, res, next) => {
|
||||
@@ -709,6 +708,18 @@ app.get('/download-client', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// SPA CATCH-ALL — Todas as rotas não-API servem o React (React Router)
|
||||
// Deve ficar APÓS todas as rotas de API e arquivos estáticos
|
||||
// ================================================================
|
||||
app.get('*', (req, res, next) => {
|
||||
// Não interceptar rotas de API, uploads ou updates
|
||||
if (req.path.startsWith('/api') || req.path.startsWith('/uploads') || req.path.startsWith('/updates') || req.path.startsWith('/socket.io')) {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(path.join(__dirname, 'dental-client', 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// SOCKET.IO - CONEXÕES EM TEMPO REAL
|
||||
// ================================================================
|
||||
|
||||
+19
-10
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# ─── deploy-prod.sh (rx.scoreodonto.com) ──────────────────────────────────────
|
||||
# GitOps: VPS4 (Builder) → VPS1 (Produção)
|
||||
# Modelo B: rsync do código-fonte → docker compose build na VPS1
|
||||
# Modelo A: build na VPS4 (inclui build React + Docker) → imagem pronta → VPS1
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
set -e
|
||||
|
||||
@@ -30,30 +30,39 @@ log "📦 git fetch + reset --hard origin/main"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
# 2. Bump de versão (sincroniza version.txt → package.json + index.html)
|
||||
# 2. Bump de versão (sincroniza version.txt)
|
||||
log "🔢 Incrementando versão..."
|
||||
node update-version.js
|
||||
|
||||
# 3. Build a imagem na VPS4 (Builder)
|
||||
# 3. Build do frontend React (Vite) — gera dental-server/dental-client/dist/
|
||||
# DEVE ocorrer ANTES do docker compose build para que o dist/ seja copiado na imagem
|
||||
log "⚛️ Build do React (Vite)..."
|
||||
cd "$PROD_PATH/dental-server/dental-client"
|
||||
cp "$PROD_PATH/dental-server/version.txt" public/version.txt
|
||||
npm install --prefer-offline
|
||||
npm run build
|
||||
cd "$PROD_PATH"
|
||||
log "✅ Build React concluído — dist/ pronto"
|
||||
|
||||
# 4. Build da imagem Docker na VPS4 (inclui dental-client/dist/)
|
||||
log "🐳 docker compose build na VPS4..."
|
||||
docker compose -f "$PROD_PATH/docker-compose.yml" build app
|
||||
|
||||
# 4. Exportar imagem para arquivo
|
||||
# 5. Exportar imagem para arquivo
|
||||
log "💾 Exportando imagem para arquivo..."
|
||||
IMAGE_NAME="rxscoreodontocom-app:latest"
|
||||
IMAGE_TAR="/tmp/rxscoreodontocom-app.tar.gz"
|
||||
docker save "$IMAGE_NAME" | gzip > "$IMAGE_TAR"
|
||||
|
||||
# 5. Enviar docker-compose.yml + imagem + versão para VPS1
|
||||
# 6. Enviar docker-compose.yml + imagem + versão para VPS1
|
||||
log "🚚 rsync docker-compose + imagem + versão → VPS1..."
|
||||
rsync -avz \
|
||||
"$PROD_PATH/docker-compose.yml" \
|
||||
"$PROD_PATH/dental-server/version.txt" \
|
||||
"$PROD_PATH/dental-server/public/index.html" \
|
||||
"$IMAGE_TAR" \
|
||||
"${PROD_USER}@${PROD_VPS}:${PROD_PATH}/"
|
||||
|
||||
# 6. Carregar imagem e subir container na VPS1
|
||||
# 7. Carregar imagem e subir container na VPS1
|
||||
log "🚀 Load imagem + docker compose up na VPS1..."
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${PROD_USER}@${PROD_VPS}" "
|
||||
set -e
|
||||
@@ -64,10 +73,10 @@ ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${PROD_USER}@${PROD_VPS}" "
|
||||
rm -f rxscoreodontocom-app.tar.gz
|
||||
"
|
||||
|
||||
# 5. Commit do bump de versão de volta ao Gitea (listener ignora → sem loop)
|
||||
# package.json não é incluído propositalmente: preserva o cache de npm install no Docker
|
||||
# 8. Commit do bump de versão de volta ao Gitea (sem loop — listener ignora)
|
||||
# package.json NÃO é incluído: preserva cache do npm install no Docker
|
||||
log "💾 git commit + push do bump de versão..."
|
||||
git add dental-server/version.txt dental-server/public/index.html
|
||||
git add dental-server/version.txt
|
||||
git commit -m "chore(deploy): bump version" || true
|
||||
git push origin main || true
|
||||
|
||||
|
||||
+4
-10
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// update-version.js — sincroniza version.txt → package.json + index.html
|
||||
// update-version.js — sincroniza version.txt
|
||||
// Incrementa o PATCH automaticamente a cada deploy.
|
||||
// Fonte de verdade: dental-server/version.txt
|
||||
// NOTA: O index.html agora é gerado pelo React/Vite — não é mais editado aqui.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const versionFile = path.join(__dirname, 'dental-server', 'version.txt');
|
||||
const indexFile = path.join(__dirname, 'dental-server', 'public', 'index.html');
|
||||
|
||||
const current = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
const parts = current.split('.').map(Number);
|
||||
@@ -14,15 +14,9 @@ parts[2]++; // bump PATCH
|
||||
const next = parts.join('.');
|
||||
|
||||
// version.txt — fonte de verdade do número de versão
|
||||
// O React serve /version.txt diretamente pelo Express static (dental-client/dist/)
|
||||
fs.writeFileSync(versionFile, next + '\n');
|
||||
|
||||
// index.html — substitui v{X.Y.Z} dentro do <span> da sidebar
|
||||
// (package.json NÃO é alterado para preservar o cache de npm install no Docker)
|
||||
let html = fs.readFileSync(indexFile, 'utf8');
|
||||
html = html.replace(
|
||||
/(RF Dental.*?<span[^>]*>)\s*v[\d.]+\s*(<\/span>)/s,
|
||||
`$1v${next}$2`
|
||||
);
|
||||
fs.writeFileSync(indexFile, html);
|
||||
// package.json NÃO é alterado para preservar o cache de npm install no Docker
|
||||
|
||||
console.log(`version: ${current} → ${next}`);
|
||||
|
||||
Reference in New Issue
Block a user