| 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> |
| ); |
| } |
|
|