HellApp / src /App.tsx
aarnal80's picture
Upload 17 files
2a40140 verified
import { useState, useEffect, useRef } from 'react';
import { User, Mode } from './types';
import { processLabsWithGemini, processTreatmentsWithGemini } from './lib/gemini';
const STORAGE_KEY = "ordenador_clinico_usuarios_v3";
function simpleHash(text: string) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = (hash << 5) - hash + text.charCodeAt(i);
hash |= 0;
}
return String(hash);
}
function xorEncrypt(text: string, key: string) {
let result = "";
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return btoa(result);
}
function xorDecrypt(base64Text: string, key: string) {
try {
const text = atob(base64Text);
let result = "";
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return result;
} catch {
return null;
}
}
export default function App() {
const [users, setUsers] = useState<User[]>([]);
const [selectedUserIndex, setSelectedUserIndex] = useState<number | "">("");
const [mode, setMode] = useState<Mode>("labs");
const [inputText, setInputText] = useState("");
const [outputText, setOutputText] = useState("");
const [status, setStatus] = useState({ message: "Listo para pegar texto.", isError: false });
const [isModalOpen, setIsModalOpen] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [newUserName, setNewUserName] = useState("");
const [newUserApiKey, setNewUserApiKey] = useState("");
const [newUserPin, setNewUserPin] = useState("");
const [unlockedUserIndex, setUnlockedUserIndex] = useState<number | null>(null);
const [unlockedApiKey, setUnlockedApiKey] = useState<string | null>(null);
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [pinInput, setPinInput] = useState("");
useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
setUsers(parsed);
}
} catch (e) {
console.error("Error loading users", e);
}
}
}, []);
const saveUsers = (newUsers: User[]) => {
setUsers(newUsers);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newUsers));
};
const handleSaveUser = () => {
if (!newUserName || !newUserApiKey || !newUserPin) {
updateStatus("Debes escribir nombre, API key y PIN.", true);
return;
}
if (newUserPin.length < 4) {
updateStatus("El PIN debe tener al menos 4 caracteres.", true);
return;
}
const encryptedApiKey = xorEncrypt(newUserApiKey, newUserPin);
const pinHash = simpleHash(newUserPin);
const existingIndex = users.findIndex(u => u.name.toLowerCase() === newUserName.toLowerCase());
const updatedUsers = [...users];
if (existingIndex >= 0) {
updatedUsers[existingIndex] = { name: newUserName, encryptedApiKey, pinHash };
} else {
updatedUsers.push({ name: newUserName, encryptedApiKey, pinHash });
}
saveUsers(updatedUsers);
setIsModalOpen(false);
setNewUserName("");
setNewUserApiKey("");
setNewUserPin("");
updateStatus(`Usuario "${newUserName}" guardado correctamente.`);
};
const updateStatus = (message: string, isError = false) => {
setStatus({ message, isError });
};
const executeProcessing = async (apiKey: string, userName: string) => {
try {
setIsProcessing(true);
updateStatus(`Procesando con Gemini para ${userName}...`);
let result = "";
if (mode === "treatments") {
result = await processTreatmentsWithGemini(apiKey, inputText);
} else {
result = await processLabsWithGemini(apiKey, inputText);
}
setOutputText(result);
updateStatus(`Procesamiento completado para ${userName}.`);
} catch (error: any) {
console.error(error);
updateStatus(error.message || "Error procesando con Gemini. Revisa la API key o la consola.", true);
} finally {
setIsProcessing(false);
}
};
const handleProcess = async () => {
if (selectedUserIndex === "") {
updateStatus("Selecciona un médico antes de procesar.", true);
return;
}
if (!inputText.trim()) {
updateStatus("Pega un texto antes de procesar.", true);
return;
}
if (unlockedUserIndex === selectedUserIndex && unlockedApiKey) {
executeProcessing(unlockedApiKey, users[selectedUserIndex as number].name);
} else {
setPinInput("");
setIsPinModalOpen(true);
}
};
const handlePinConfirm = () => {
if (selectedUserIndex === "") return;
const currentUser = users[selectedUserIndex as number];
if (simpleHash(pinInput) !== currentUser.pinHash) {
updateStatus("PIN incorrecto.", true);
return;
}
const apiKey = xorDecrypt(currentUser.encryptedApiKey, pinInput);
if (!apiKey) {
updateStatus("No se pudo desbloquear la API key.", true);
return;
}
setUnlockedUserIndex(selectedUserIndex as number);
setUnlockedApiKey(apiKey);
setIsPinModalOpen(false);
executeProcessing(apiKey, currentUser.name);
};
const handleCopy = async () => {
if (!outputText.trim()) {
updateStatus("No hay resultado para copiar.", true);
return;
}
try {
await navigator.clipboard.writeText(outputText);
updateStatus("Resultado copiado al portapapeles.");
} catch {
updateStatus("No se pudo copiar el resultado.", true);
}
};
const handleLock = () => {
setUnlockedUserIndex(null);
setUnlockedApiKey(null);
updateStatus("Usuario bloqueado.");
};
return (
<div className="app-container">
<header className="app-header">
<div>
<h1 className="text-3xl font-bold text-slate-900">Ordenador Clínico IA</h1>
<p className="subtitle">Analíticas y tratamientos con anonimización local</p>
</div>
<div className="user-bar">
<label htmlFor="userSelect">Usuario</label>
<select
id="userSelect"
value={selectedUserIndex}
onChange={(e) => {
setSelectedUserIndex(e.target.value === "" ? "" : Number(e.target.value));
setUnlockedUserIndex(null);
setUnlockedApiKey(null);
}}
className="bg-white border border-slate-200 rounded-lg px-3 py-2"
>
<option value="">Seleccionar médico</option>
{users.map((user, index) => (
<option key={index} value={index}>{user.name}</option>
))}
</select>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Configurar usuarios
</button>
<button
onClick={handleLock}
className="bg-slate-600 hover:bg-slate-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Bloquear
</button>
</div>
</header>
<main className="main-content">
<section className="controls-panel">
<div className="mode-selector flex items-center gap-4 mb-4">
<span className="font-medium">Modo:</span>
<label className="radio-option flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mode"
value="labs"
checked={mode === "labs"}
onChange={() => setMode("labs")}
className="w-4 h-4 text-blue-600"
/>
<span>Analíticas</span>
</label>
<label className="radio-option flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mode"
value="treatments"
checked={mode === "treatments"}
onChange={() => setMode("treatments")}
className="w-4 h-4 text-blue-600"
/>
<span>Tratamientos</span>
</label>
</div>
<div className="actions-row flex gap-3 mb-4">
<button
onClick={handleProcess}
disabled={isProcessing}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${isProcessing ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
>
{isProcessing ? 'Procesando...' : 'Procesar'}
</button>
<button
onClick={() => {
setInputText("");
setOutputText("");
updateStatus("Campos limpiados.");
}}
className="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg transition-colors"
>
Limpiar
</button>
<button
onClick={handleCopy}
className="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg transition-colors"
>
Copiar resultado
</button>
</div>
<div
className={`status-message ${status.isError ? 'bg-red-50 text-red-800' : 'bg-emerald-50 text-emerald-800'}`}
>
{status.message}
</div>
</section>
<section className="workspace grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="panel flex flex-col h-[520px]">
<div className="panel-header p-4 pb-0">
<h2 className="text-lg font-semibold">Texto de entrada</h2>
</div>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Pega aquí la analítica, medicación o informe..."
className="flex-1 m-4 p-4 border border-slate-200 rounded-xl resize-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none bg-slate-50/50"
spellCheck="false"
/>
</div>
<div className="panel flex flex-col h-[520px]">
<div className="panel-header p-4 pb-0">
<h2 className="text-lg font-semibold">Resultado</h2>
</div>
<textarea
value={outputText}
readOnly
placeholder="Aquí aparecerá el resultado ordenado..."
className="flex-1 m-4 p-4 border border-slate-200 rounded-xl resize-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none bg-slate-50/50"
spellCheck="false"
/>
</div>
</section>
</main>
{isModalOpen && (
<div className="modal" onClick={() => setIsModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header flex justify-between items-center p-5 border-b border-slate-100">
<h2 className="text-xl font-bold">Configuración de usuarios</h2>
<button onClick={() => setIsModalOpen(false)} className="text-2xl text-slate-400 hover:text-slate-600">✕</button>
</div>
<div className="modal-body p-6">
<div className="form-group flex flex-col gap-2 mb-4">
<label className="text-sm text-slate-500">Nombre del médico</label>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Ej: Doctor Arnal"
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
/>
</div>
<div className="form-group flex flex-col gap-2 mb-4">
<label className="text-sm text-slate-500">API Key de Gemini</label>
<input
type="password"
value={newUserApiKey}
onChange={(e) => setNewUserApiKey(e.target.value)}
placeholder="Pega aquí la API key"
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
/>
</div>
<div className="form-group flex flex-col gap-2 mb-6">
<label className="text-sm text-slate-500">PIN del usuario</label>
<input
type="password"
value={newUserPin}
onChange={(e) => setNewUserPin(e.target.value)}
placeholder="Mínimo 4 caracteres"
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
/>
</div>
<div className="modal-actions mb-6">
<button
onClick={handleSaveUser}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
>
Guardar usuario
</button>
</div>
<hr className="border-slate-100 mb-6" />
<div>
<h3 className="font-semibold mb-4">Usuarios guardados</h3>
<ul className="users-list space-y-3">
{users.map((user, index) => (
<li key={index} className="p-3 bg-slate-50 border border-slate-200 rounded-xl">
<strong className="block text-slate-900">{user.name}</strong>
<small className="text-slate-500">
API guardada: {user.encryptedApiKey ? "Sí" : "No"} | PIN: {user.pinHash ? "Sí" : "No"}
</small>
</li>
))}
{users.length === 0 && (
<p className="text-slate-400 text-sm italic">No hay usuarios configurados.</p>
)}
</ul>
</div>
</div>
</div>
</div>
)}
{isPinModalOpen && (
<div className="modal" onClick={() => setIsPinModalOpen(false)}>
<div className="modal-content max-w-sm" onClick={(e) => e.stopPropagation()}>
<div className="modal-header flex justify-between items-center p-5 border-b border-slate-100">
<h2 className="text-xl font-bold">Desbloquear Usuario</h2>
<button onClick={() => setIsPinModalOpen(false)} className="text-2xl text-slate-400 hover:text-slate-600">✕</button>
</div>
<div className="modal-body p-6">
<p className="text-slate-600 mb-4">
Introduce el PIN de <strong>{selectedUserIndex !== "" ? users[selectedUserIndex as number].name : ""}</strong> para procesar.
</p>
<div className="form-group flex flex-col gap-2 mb-6">
<input
type="password"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePinConfirm()}
placeholder="PIN de seguridad"
autoFocus
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500 text-center text-2xl tracking-widest"
/>
</div>
<div className="modal-actions">
<button
onClick={handlePinConfirm}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
>
Confirmar y Procesar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}