From 20e32534722b34222d03ef003bfcdced7ee793f4 Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Thu, 13 Nov 2025 12:57:22 -0300 Subject: [PATCH 01/12] fix(pacientes) adicionei o Chat Widget no modulo de paciemtes --- .../app/(main-routes)/pacientes/layout.tsx | 11 + susconecta/app/paciente/layout.tsx | 11 + .../ZoeIA/ai-assistant-interface.tsx | 512 ++++++++++++++++++ .../components/ZoeIA/demo-voice-orb.tsx | 107 ++++ susconecta/components/ZoeIA/demo.tsx | 10 + .../components/ZoeIA/voice-powered-orb.tsx | 493 +++++++++++++++++ .../features/pacientes/chat-widget.tsx | 182 +++++++ susconecta/types/ogl.d.ts | 1 + 8 files changed, 1327 insertions(+) create mode 100644 susconecta/app/(main-routes)/pacientes/layout.tsx create mode 100644 susconecta/app/paciente/layout.tsx create mode 100644 susconecta/components/ZoeIA/ai-assistant-interface.tsx create mode 100644 susconecta/components/ZoeIA/demo-voice-orb.tsx create mode 100644 susconecta/components/ZoeIA/demo.tsx create mode 100644 susconecta/components/ZoeIA/voice-powered-orb.tsx create mode 100644 susconecta/components/features/pacientes/chat-widget.tsx create mode 100644 susconecta/types/ogl.d.ts diff --git a/susconecta/app/(main-routes)/pacientes/layout.tsx b/susconecta/app/(main-routes)/pacientes/layout.tsx new file mode 100644 index 0000000..4e9bd5c --- /dev/null +++ b/susconecta/app/(main-routes)/pacientes/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { ChatWidget } from "@/components/features/pacientes/chat-widget"; + +export default function PacientesLayout({ children }: { children: ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/susconecta/app/paciente/layout.tsx b/susconecta/app/paciente/layout.tsx new file mode 100644 index 0000000..1ff2978 --- /dev/null +++ b/susconecta/app/paciente/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { ChatWidget } from "@/components/features/pacientes/chat-widget"; + +export default function PacienteLayout({ children }: { children: ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/susconecta/components/ZoeIA/ai-assistant-interface.tsx b/susconecta/components/ZoeIA/ai-assistant-interface.tsx new file mode 100644 index 0000000..b99d520 --- /dev/null +++ b/susconecta/components/ZoeIA/ai-assistant-interface.tsx @@ -0,0 +1,512 @@ +"use client"; + +import type React from "react"; + +import { useState, useRef } from "react"; +import { + Search, + Mic, + ArrowUp, + Plus, + FileText, + Code, + BookOpen, + PenTool, + BrainCircuit, + Sparkles, +} from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; + +export function AIAssistantInterface() { + const [inputValue, setInputValue] = useState(""); + const [searchEnabled, setSearchEnabled] = useState(false); + const [deepResearchEnabled, setDeepResearchEnabled] = useState(false); + const [reasonEnabled, setReasonEnabled] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [showUploadAnimation, setShowUploadAnimation] = useState(false); + const [activeCommandCategory, setActiveCommandCategory] = useState< + string | null + >(null); + const inputRef = useRef(null); + + const commandSuggestions = { + learn: [ + "Explain the Big Bang theory", + "How does photosynthesis work?", + "What are black holes?", + "Explain quantum computing", + "How does the human brain work?", + ], + code: [ + "Create a React component for a todo list", + "Write a Python function to sort a list", + "How to implement authentication in Next.js", + "Explain async/await in JavaScript", + "Create a CSS animation for a button", + ], + write: [ + "Write a professional email to a client", + "Create a product description for a smartphone", + "Draft a blog post about AI", + "Write a creative story about space exploration", + "Create a social media post about sustainability", + ], + }; + + const handleUploadFile = () => { + setShowUploadAnimation(true); + + // Simulate file upload with timeout + setTimeout(() => { + const newFile = `Document.pdf`; + setUploadedFiles((prev) => [...prev, newFile]); + setShowUploadAnimation(false); + }, 1500); + }; + + const handleCommandSelect = (command: string) => { + setInputValue(command); + setActiveCommandCategory(null); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const handleSendMessage = () => { + if (inputValue.trim()) { + console.log("Sending message:", inputValue); + setInputValue(""); + } + }; + + return ( +
+
+ {/* Logo with animated gradient */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* Welcome message */} +
+ +

+ Ready to assist you +

+

+ Ask me anything or try one of the suggestions below +

+
+
+ + {/* Input area with integrated functions and file upload */} +
+
+ setInputValue(e.target.value)} + className="w-full text-gray-700 text-base outline-none placeholder:text-gray-400" + /> +
+ + {/* Uploaded files */} + {uploadedFiles.length > 0 && ( +
+
+ {uploadedFiles.map((file, index) => ( +
+ + {file} + +
+ ))} +
+
+ )} + + {/* Search, Deep Research, Reason functions and actions */} +
+
+ + + +
+
+ + +
+
+ + {/* Upload files */} +
+ +
+
+ + {/* Command categories */} +
+ } + label="Learn" + isActive={activeCommandCategory === "learn"} + onClick={() => + setActiveCommandCategory( + activeCommandCategory === "learn" ? null : "learn" + ) + } + /> + } + label="Code" + isActive={activeCommandCategory === "code"} + onClick={() => + setActiveCommandCategory( + activeCommandCategory === "code" ? null : "code" + ) + } + /> + } + label="Write" + isActive={activeCommandCategory === "write"} + onClick={() => + setActiveCommandCategory( + activeCommandCategory === "write" ? null : "write" + ) + } + /> +
+ + {/* Command suggestions */} + + {activeCommandCategory && ( + +
+
+

+ {activeCommandCategory === "learn" + ? "Learning suggestions" + : activeCommandCategory === "code" + ? "Coding suggestions" + : "Writing suggestions"} +

+
+
    + {commandSuggestions[ + activeCommandCategory as keyof typeof commandSuggestions + ].map((suggestion, index) => ( + handleCommandSelect(suggestion)} + className="p-3 hover:bg-gray-50 cursor-pointer transition-colors duration-75" + > +
    + {activeCommandCategory === "learn" ? ( + + ) : activeCommandCategory === "code" ? ( + + ) : ( + + )} + + {suggestion} + +
    +
    + ))} +
+
+
+ )} +
+
+
+ ); +} + +interface CommandButtonProps { + icon: React.ReactNode; + label: string; + isActive: boolean; + onClick: () => void; +} + +function CommandButton({ icon, label, isActive, onClick }: CommandButtonProps) { + return ( + +
+ {icon} +
+ + {label} + +
+ ); +} diff --git a/susconecta/components/ZoeIA/demo-voice-orb.tsx b/susconecta/components/ZoeIA/demo-voice-orb.tsx new file mode 100644 index 0000000..66087c0 --- /dev/null +++ b/susconecta/components/ZoeIA/demo-voice-orb.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb"; +import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Mic, MicOff } from "lucide-react"; + +export default function VoicePoweredOrbPage() { + const [isRecording, setIsRecording] = useState(false); + const [voiceDetected, setVoiceDetected] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(false); + + const toggleRecording = () => { + setIsRecording(!isRecording); + }; + + useEffect(() => { + if (!assistantOpen) return; + + const original = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = original; + }; + }, [assistantOpen]); + + const openAssistant = () => setAssistantOpen(true); + const closeAssistant = () => setAssistantOpen(false); + + return ( +
+
+ {assistantOpen && ( +
+
+ +
+
+ +
+
+ )} + + {/* Orb */} +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openAssistant(); + } + }} + > + + {voiceDetected && ( + + Ouvindo… + + )} +
+ + {/* Control Button */} + + + {/* Simple Instructions */} +

+ Click the button to enable voice control. Speak to see the orb respond to your voice with subtle movements. +

+
+
+ ); +} diff --git a/susconecta/components/ZoeIA/demo.tsx b/susconecta/components/ZoeIA/demo.tsx new file mode 100644 index 0000000..be4c889 --- /dev/null +++ b/susconecta/components/ZoeIA/demo.tsx @@ -0,0 +1,10 @@ +import * as React from "react" +import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface" + +export function Demo() { + return ( +
+ +
+ ) +} diff --git a/susconecta/components/ZoeIA/voice-powered-orb.tsx b/susconecta/components/ZoeIA/voice-powered-orb.tsx new file mode 100644 index 0000000..ca28076 --- /dev/null +++ b/susconecta/components/ZoeIA/voice-powered-orb.tsx @@ -0,0 +1,493 @@ +"use client"; + +import React, { useEffect, useRef, FC } from "react"; +import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl"; +import { cn } from "@/lib/utils"; + +interface VoicePoweredOrbProps { + className?: string; + hue?: number; + enableVoiceControl?: boolean; + voiceSensitivity?: number; + maxRotationSpeed?: number; + maxHoverIntensity?: number; + onVoiceDetected?: (detected: boolean) => void; +} + +export const VoicePoweredOrb: FC = ({ + className, + hue = 0, + enableVoiceControl = true, + voiceSensitivity = 1.5, + maxRotationSpeed = 1.2, + maxHoverIntensity = 0.8, + onVoiceDetected, +}) => { + const ctnDom = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const microphoneRef = useRef(null); + const dataArrayRef = useRef(null); + const animationFrameRef = useRef(); + const mediaStreamRef = useRef(null); + + const vert = /* glsl */ ` + precision highp float; + attribute vec2 position; + attribute vec2 uv; + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position, 0.0, 1.0); + } + `; + + const frag = /* glsl */ ` + precision highp float; + + uniform float iTime; + uniform vec3 iResolution; + uniform float hue; + uniform float hover; + uniform float rot; + uniform float hoverIntensity; + varying vec2 vUv; + + vec3 rgb2yiq(vec3 c) { + float y = dot(c, vec3(0.299, 0.587, 0.114)); + float i = dot(c, vec3(0.596, -0.274, -0.322)); + float q = dot(c, vec3(0.211, -0.523, 0.312)); + return vec3(y, i, q); + } + + vec3 yiq2rgb(vec3 c) { + float r = c.x + 0.956 * c.y + 0.621 * c.z; + float g = c.x - 0.272 * c.y - 0.647 * c.z; + float b = c.x - 1.106 * c.y + 1.703 * c.z; + return vec3(r, g, b); + } + + vec3 adjustHue(vec3 color, float hueDeg) { + float hueRad = hueDeg * 3.14159265 / 180.0; + vec3 yiq = rgb2yiq(color); + float cosA = cos(hueRad); + float sinA = sin(hueRad); + float i = yiq.y * cosA - yiq.z * sinA; + float q = yiq.y * sinA + yiq.z * cosA; + yiq.y = i; + yiq.z = q; + return yiq2rgb(yiq); + } + + vec3 hash33(vec3 p3) { + p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787)); + p3 += dot(p3, p3.yxz + 19.19); + return -1.0 + 2.0 * fract(vec3( + p3.x + p3.y, + p3.x + p3.z, + p3.y + p3.z + ) * p3.zyx); + } + + float snoise3(vec3 p) { + const float K1 = 0.333333333; + const float K2 = 0.166666667; + vec3 i = floor(p + (p.x + p.y + p.z) * K1); + vec3 d0 = p - (i - (i.x + i.y + i.z) * K2); + vec3 e = step(vec3(0.0), d0 - d0.yzx); + vec3 i1 = e * (1.0 - e.zxy); + vec3 i2 = 1.0 - e.zxy * (1.0 - e); + vec3 d1 = d0 - (i1 - K2); + vec3 d2 = d0 - (i2 - K1); + vec3 d3 = d0 - 0.5; + vec4 h = max(0.6 - vec4( + dot(d0, d0), + dot(d1, d1), + dot(d2, d2), + dot(d3, d3) + ), 0.0); + vec4 n = h * h * h * h * vec4( + dot(d0, hash33(i)), + dot(d1, hash33(i + i1)), + dot(d2, hash33(i + i2)), + dot(d3, hash33(i + 1.0)) + ); + return dot(vec4(31.316), n); + } + + vec4 extractAlpha(vec3 colorIn) { + float a = max(max(colorIn.r, colorIn.g), colorIn.b); + return vec4(colorIn.rgb / (a + 1e-5), a); + } + + const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078); + const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725); + const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000); + const float innerRadius = 0.6; + const float noiseScale = 0.65; + + float light1(float intensity, float attenuation, float dist) { + return intensity / (1.0 + dist * attenuation); + } + + float light2(float intensity, float attenuation, float dist) { + return intensity / (1.0 + dist * dist * attenuation); + } + + vec4 draw(vec2 uv) { + vec3 color1 = adjustHue(baseColor1, hue); + vec3 color2 = adjustHue(baseColor2, hue); + vec3 color3 = adjustHue(baseColor3, hue); + + float ang = atan(uv.y, uv.x); + float len = length(uv); + float invLen = len > 0.0 ? 1.0 / len : 0.0; + + float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5; + float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0); + float d0 = distance(uv, (r0 * invLen) * uv); + float v0 = light1(1.0, 10.0, d0); + v0 *= smoothstep(r0 * 1.05, r0, len); + float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5; + + float a = iTime * -1.0; + vec2 pos = vec2(cos(a), sin(a)) * r0; + float d = distance(uv, pos); + float v1 = light2(1.5, 5.0, d); + v1 *= light1(1.0, 50.0, d0); + + float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len); + float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len); + + vec3 col = mix(color1, color2, cl); + col = mix(color3, col, v0); + col = (col + v1) * v2 * v3; + col = clamp(col, 0.0, 1.0); + + return extractAlpha(col); + } + + vec4 mainImage(vec2 fragCoord) { + vec2 center = iResolution.xy * 0.5; + float size = min(iResolution.x, iResolution.y); + vec2 uv = (fragCoord - center) / size * 2.0; + + float angle = rot; + float s = sin(angle); + float c = cos(angle); + uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); + + uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime); + uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime); + + return draw(uv); + } + + void main() { + vec2 fragCoord = vUv * iResolution.xy; + vec4 col = mainImage(fragCoord); + gl_FragColor = vec4(col.rgb * col.a, col.a); + } + `; + + // Voice analysis function + const analyzeAudio = () => { + if (!analyserRef.current || !dataArrayRef.current) return 0; + + // To avoid type incompatibilities between different ArrayBuffer-like types + // (Uint8Array vs Uint8Array), create a + // standard Uint8Array copy with an ArrayBuffer backing it. This satisfies + // the Web Audio API typing and is safe (small cost to copy). + const src = dataArrayRef.current as Uint8Array; + const buffer = Uint8Array.from(src); + analyserRef.current.getByteFrequencyData(buffer); + + // Calculate RMS (Root Mean Square) for better voice detection + let sum = 0; + for (let i = 0; i < buffer.length; i++) { + const value = buffer[i] / 255; + sum += value * value; + } + const rms = Math.sqrt(sum / buffer.length); + + // Apply sensitivity and boost the signal + const level = Math.min(rms * voiceSensitivity * 3.0, 1); + + return level; + }; + + // Stop microphone and cleanup + const stopMicrophone = () => { + try { + // Stop all tracks in the media stream + if (mediaStreamRef.current) { + mediaStreamRef.current.getTracks().forEach(track => { + track.stop(); + }); + mediaStreamRef.current = null; + } + + // Disconnect and cleanup audio nodes + if (microphoneRef.current) { + microphoneRef.current.disconnect(); + microphoneRef.current = null; + } + + if (analyserRef.current) { + analyserRef.current.disconnect(); + analyserRef.current = null; + } + + // Close audio context + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.close(); + audioContextRef.current = null; + } + + dataArrayRef.current = null; + console.log('Microphone stopped and cleaned up'); + } catch (error) { + console.warn('Error stopping microphone:', error); + } + }; + + // Initialize microphone access + const initMicrophone = async () => { + try { + // Clean up any existing microphone first + stopMicrophone(); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + sampleRate: 44100, + }, + }); + + mediaStreamRef.current = stream; + + audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + + if (audioContextRef.current.state === 'suspended') { + await audioContextRef.current.resume(); + } + + analyserRef.current = audioContextRef.current.createAnalyser(); + microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream); + + analyserRef.current.fftSize = 512; + analyserRef.current.smoothingTimeConstant = 0.3; + analyserRef.current.minDecibels = -90; + analyserRef.current.maxDecibels = -10; + + microphoneRef.current.connect(analyserRef.current); + dataArrayRef.current = new Uint8Array(analyserRef.current.frequencyBinCount); + + console.log('Microphone initialized successfully'); + return true; + } catch (error) { + console.warn("Microphone access denied or not available:", error); + return false; + } + }; + + useEffect(() => { + const container = ctnDom.current; + if (!container) return; + + let rendererInstance: any = null; + let glContext: WebGLRenderingContext | WebGL2RenderingContext | null = null; + let rafId: number; + let program: any = null; + + try { + rendererInstance = new Renderer({ + alpha: true, + premultipliedAlpha: false, + antialias: true, + dpr: window.devicePixelRatio || 1 + }); + glContext = rendererInstance.gl as WebGLRenderingContext; + glContext.clearColor(0, 0, 0, 0); + glContext.enable((glContext as any).BLEND); + glContext.blendFunc((glContext as any).SRC_ALPHA, (glContext as any).ONE_MINUS_SRC_ALPHA); + + while (container.firstChild) { + container.removeChild(container.firstChild); + } + container.appendChild((glContext as any).canvas); + + const geometry = new Triangle(glContext as any); + program = new Program(glContext as any, { + vertex: vert, + fragment: frag, + uniforms: { + iTime: { value: 0 }, + iResolution: { + value: new Vec3( + (glContext as any).canvas.width, + (glContext as any).canvas.height, + (glContext as any).canvas.width / (glContext as any).canvas.height + ), + }, + hue: { value: hue }, + hover: { value: 0 }, + rot: { value: 0 }, + hoverIntensity: { value: 0 }, + }, + }); + + const mesh = new Mesh(glContext as any, { geometry, program }); + + const resize = () => { + if (!container || !rendererInstance || !glContext) return; + const dpr = window.devicePixelRatio || 1; + const width = container.clientWidth; + const height = container.clientHeight; + + if (width === 0 || height === 0) return; + + rendererInstance.setSize(width * dpr, height * dpr); + (glContext as any).canvas.style.width = width + "px"; + (glContext as any).canvas.style.height = height + "px"; + + if (program) { + program.uniforms.iResolution.value.set( + (glContext as any).canvas.width, + (glContext as any).canvas.height, + (glContext as any).canvas.width / (glContext as any).canvas.height + ); + } + }; + window.addEventListener("resize", resize); + resize(); + + let lastTime = 0; + let currentRot = 0; + let voiceLevel = 0; + const baseRotationSpeed = 0.3; + let isMicrophoneInitialized = false; + + if (enableVoiceControl) { + initMicrophone().then((success) => { + isMicrophoneInitialized = success; + }); + } else { + stopMicrophone(); + isMicrophoneInitialized = false; + } + + const update = (t: number) => { + rafId = requestAnimationFrame(update); + if (!program) return; + + const dt = (t - lastTime) * 0.001; + lastTime = t; + program.uniforms.iTime.value = t * 0.001; + program.uniforms.hue.value = hue; + + if (enableVoiceControl && isMicrophoneInitialized) { + voiceLevel = analyzeAudio(); + + if (onVoiceDetected) { + onVoiceDetected(voiceLevel > 0.1); + } + + const voiceRotationSpeed = baseRotationSpeed + (voiceLevel * maxRotationSpeed * 2.0); + + if (voiceLevel > 0.05) { + currentRot += dt * voiceRotationSpeed; + } + + program.uniforms.hover.value = Math.min(voiceLevel * 2.0, 1.0); + program.uniforms.hoverIntensity.value = Math.min(voiceLevel * maxHoverIntensity * 0.8, maxHoverIntensity); + } else { + program.uniforms.hover.value = 0; + program.uniforms.hoverIntensity.value = 0; + if (onVoiceDetected) { + onVoiceDetected(false); + } + } + + program.uniforms.rot.value = currentRot; + + if (rendererInstance && glContext) { + glContext.clear((glContext as any).COLOR_BUFFER_BIT | (glContext as any).DEPTH_BUFFER_BIT); + rendererInstance.render({ scene: mesh }); + } + }; + + rafId = requestAnimationFrame(update); + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener("resize", resize); + + try { + if (container && glContext && (glContext as any).canvas) { + if (container.contains((glContext as any).canvas)) { + container.removeChild((glContext as any).canvas); + } + } + } catch (error) { + console.warn("Canvas cleanup error:", error); + } + + stopMicrophone(); + + if (glContext) { + (glContext as any).getExtension("WEBGL_lose_context")?.loseContext(); + } + }; + + } catch (error) { + console.error("Error initializing Voice Powered Orb:", error); + if (container && container.firstChild) { + container.removeChild(container.firstChild); + } + return () => { + window.removeEventListener("resize", () => {}); + }; + } + }, [ + hue, + enableVoiceControl, + voiceSensitivity, + maxRotationSpeed, + maxHoverIntensity, + vert, + frag, + ]); + + useEffect(() => { + let isMounted = true; + + const handleMicrophoneState = async () => { + if (enableVoiceControl) { + const success = await initMicrophone(); + if (!isMounted) return; + } else { + stopMicrophone(); + } + }; + + handleMicrophoneState(); + + return () => { + isMounted = false; + }; + }, [enableVoiceControl]); + + return ( +
+
+ ); +}; diff --git a/susconecta/components/features/pacientes/chat-widget.tsx b/susconecta/components/features/pacientes/chat-widget.tsx new file mode 100644 index 0000000..e80dd4b --- /dev/null +++ b/susconecta/components/features/pacientes/chat-widget.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Sparkles, MessageCircle, X, Send } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +const cannedSuggestions = [ + "Como remarcar minha consulta?", + "Quais documentos preciso levar?", + "Quero falar com suporte humano", +]; + +const supportAvailability = { + title: "Equipe disponível", + description: "Seg–Sex das 08h às 18h", +}; + +interface ChatMessage { + id: string; + author: "assistant" | "user"; + text: string; + timestamp: string; +} + +export function ChatWidget() { + const [open, setOpen] = useState(false); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState(() => [ + { + id: "welcome", + author: "assistant", + text: "Olá! Sou sua assistente virtual. Posso ajudar a acompanhar consultas, exames e suporte geral.", + timestamp: new Date().toISOString(), + }, + ]); + + const toggle = () => setOpen((prev) => !prev); + + const handleSend = () => { + const trimmed = input.trim(); + if (!trimmed) return; + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { id: `user-${now}`, author: "user", text: trimmed, timestamp: now }, + { + id: `assistant-${now}`, + author: "assistant", + text: "Recebi sua mensagem! Nossa equipe retornará em breve.", + timestamp: now, + }, + ]); + setInput(""); + }; + + const gradientRing = useMemo( + () => ( + + ), + [] + ); + + return ( +
+ {open && ( +
+
+
+ +
+
+

Assistente RiseUp

+

Pronta para ajudar no que você precisar

+
+ +
+ +
+ {messages.map((message) => ( +
+ + {message.author === "assistant" ? : } + +
+ {message.text} +
+
+ ))} +
+ +
+
+

{supportAvailability.title}

+

{supportAvailability.description}

+
+
+ {cannedSuggestions.map((suggestion) => ( + + ))} +
+
+ setInput(event.target.value)} + placeholder="Escreva sua mensagem" + className="border-none px-0 text-sm focus-visible:ring-0" + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleSend(); + } + }} + /> + +
+
+
+ )} + + +
+ ); +} diff --git a/susconecta/types/ogl.d.ts b/susconecta/types/ogl.d.ts new file mode 100644 index 0000000..e897bbb --- /dev/null +++ b/susconecta/types/ogl.d.ts @@ -0,0 +1 @@ +declare module 'ogl'; -- 2.47.2 From 8ce02ac5ad30266cbb63b4adf00897db5d4bb7fd Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Thu, 13 Nov 2025 13:39:51 -0300 Subject: [PATCH 02/12] fix(paciente) adicionei o chat interface --- .../ZoeIA/ai-assistant-interface.tsx | 761 +++++++----------- .../features/pacientes/chat-widget.tsx | 221 ++--- 2 files changed, 356 insertions(+), 626 deletions(-) diff --git a/susconecta/components/ZoeIA/ai-assistant-interface.tsx b/susconecta/components/ZoeIA/ai-assistant-interface.tsx index b99d520..75481bc 100644 --- a/susconecta/components/ZoeIA/ai-assistant-interface.tsx +++ b/susconecta/components/ZoeIA/ai-assistant-interface.tsx @@ -1,512 +1,317 @@ "use client"; -import type React from "react"; - -import { useState, useRef } from "react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle"; import { - Search, - Mic, - ArrowUp, + Clock, + Info, + Lock, + MessageCircle, Plus, - FileText, - Code, - BookOpen, - PenTool, - BrainCircuit, - Sparkles, + Upload, } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -export function AIAssistantInterface() { - const [inputValue, setInputValue] = useState(""); - const [searchEnabled, setSearchEnabled] = useState(false); - const [deepResearchEnabled, setDeepResearchEnabled] = useState(false); - const [reasonEnabled, setReasonEnabled] = useState(false); - const [uploadedFiles, setUploadedFiles] = useState([]); - const [showUploadAnimation, setShowUploadAnimation] = useState(false); - const [activeCommandCategory, setActiveCommandCategory] = useState< - string | null - >(null); - const inputRef = useRef(null); +interface HistoryEntry { + id: string; + text: string; + createdAt: string; +} - const commandSuggestions = { - learn: [ - "Explain the Big Bang theory", - "How does photosynthesis work?", - "What are black holes?", - "Explain quantum computing", - "How does the human brain work?", - ], - code: [ - "Create a React component for a todo list", - "Write a Python function to sort a list", - "How to implement authentication in Next.js", - "Explain async/await in JavaScript", - "Create a CSS animation for a button", - ], - write: [ - "Write a professional email to a client", - "Create a product description for a smartphone", - "Draft a blog post about AI", - "Write a creative story about space exploration", - "Create a social media post about sustainability", - ], - }; +interface AIAssistantInterfaceProps { + onOpenDocuments?: () => void; + onOpenChat?: () => void; + history?: HistoryEntry[]; + onAddHistory?: (entry: HistoryEntry) => void; + onClearHistory?: () => void; +} - const handleUploadFile = () => { - setShowUploadAnimation(true); +export function AIAssistantInterface({ + onOpenDocuments, + onOpenChat, + history: externalHistory, + onAddHistory, + onClearHistory, +}: AIAssistantInterfaceProps) { + const [question, setQuestion] = useState(""); + const [drawerOpen, setDrawerOpen] = useState(false); + const [internalHistory, setInternalHistory] = useState([]); + const history = externalHistory ?? internalHistory; - // Simulate file upload with timeout - setTimeout(() => { - const newFile = `Document.pdf`; - setUploadedFiles((prev) => [...prev, newFile]); - setShowUploadAnimation(false); - }, 1500); - }; + const showHistoryBadge = useMemo(() => history.length > 0, [history.length]); - const handleCommandSelect = (command: string) => { - setInputValue(command); - setActiveCommandCategory(null); - - if (inputRef.current) { - inputRef.current.focus(); + const handleDocuments = () => { + if (onOpenDocuments) { + onOpenDocuments(); + return; } + console.log("[ZoeIA] Abrir fluxo de documentos"); + }; + + const handleOpenRealtimeChat = () => { + if (onOpenChat) { + onOpenChat(); + return; + } + console.log("[ZoeIA] Abrir chat em tempo real"); }; const handleSendMessage = () => { - if (inputValue.trim()) { - console.log("Sending message:", inputValue); - setInputValue(""); + const trimmed = question.trim(); + if (!trimmed) return; + + handlePersistHistory(trimmed); + console.log("[ZoeIA] Mensagem enviada para Zoe", trimmed); + setQuestion(""); + }; + + const RealtimeTriggerButton = () => ( + + ); + + const handlePersistHistory = (text: string) => { + const entry: HistoryEntry = { + id: `hist-${Date.now()}`, + text, + createdAt: new Date().toISOString(), + }; + + if (onAddHistory) { + onAddHistory(entry); + } else { + setInternalHistory((prev) => [...prev, entry]); + } + setDrawerOpen(true); + }; + + const handleClearHistory = () => { + if (onClearHistory) { + onClearHistory(); + } else { + setInternalHistory([]); } }; + const HistoryGlyph = () => ( + + + + + + + ); + return ( -
-
- {/* Logo with animated gradient */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - +
+
+
+ + + Zoe + +
+

Assistente Clínica Zoe

+

Olá, eu sou Zoe. Como posso ajudar hoje?

+
+
+ +
+ +
+ + Suas informações permanecem criptografadas e seguras com a equipe Zoe.
- {/* Welcome message */} -
- -

- Ready to assist you -

-

- Ask me anything or try one of the suggestions below +

+
+
+ + Informativo importante +
+

+ A Zoe é a assistente virtual da Clínica Zoe. Ela reúne informações sobre seus cuidados e orienta os próximos passos. + O atendimento é informativo e não substitui a avaliação de um profissional de saúde qualificado.

- +

+ Em situações de urgência, procure imediatamente o suporte médico presencial ou ligue para os serviços de emergência. +

+
+ +
+ + +
+ +
+

+ Estamos reunindo o histórico da sua jornada. Enquanto isso, você pode anexar exames, enviar dúvidas ou solicitar contato com a equipe Zoe. +

+
+
+ +
+ + setQuestion(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleSendMessage(); + } + }} + placeholder="Pergunte qualquer coisa para a Zoe" + className="border-none bg-transparent text-sm shadow-none focus-visible:ring-0" + /> +
+ + +
- {/* Input area with integrated functions and file upload */} -
-
- setInputValue(e.target.value)} - className="w-full text-gray-700 text-base outline-none placeholder:text-gray-400" - /> -
- - {/* Uploaded files */} - {uploadedFiles.length > 0 && ( -
-
- {uploadedFiles.map((file, index) => ( -
- - {file} - + {drawerOpen && ( +
- - {/* Command categories */} -
- } - label="Learn" - isActive={activeCommandCategory === "learn"} - onClick={() => - setActiveCommandCategory( - activeCommandCategory === "learn" ? null : "learn" - ) - } - /> - } - label="Code" - isActive={activeCommandCategory === "code"} - onClick={() => - setActiveCommandCategory( - activeCommandCategory === "code" ? null : "code" - ) - } - /> - } - label="Write" - isActive={activeCommandCategory === "write"} - onClick={() => - setActiveCommandCategory( - activeCommandCategory === "write" ? null : "write" - ) - } - /> -
- - {/* Command suggestions */} - - {activeCommandCategory && ( - -
-
-

- {activeCommandCategory === "learn" - ? "Learning suggestions" - : activeCommandCategory === "code" - ? "Coding suggestions" - : "Writing suggestions"} -

-
-
    - {commandSuggestions[ - activeCommandCategory as keyof typeof commandSuggestions - ].map((suggestion, index) => ( - handleCommandSelect(suggestion)} - className="p-3 hover:bg-gray-50 cursor-pointer transition-colors duration-75" - > -
    - {activeCommandCategory === "learn" ? ( - - ) : activeCommandCategory === "code" ? ( - - ) : ( - - )} - - {suggestion} - -
    -
    - ))} -
+ + Novo atendimento +
-
- )} -
+ +
+ {history.length === 0 ? ( +

+ Nenhuma conversa registrada ainda. Envie uma mensagem para começar um novo acompanhamento com a Zoe. +

+ ) : ( +
    + {[...history] + .reverse() + .map((entry) => ( +
  • + + + +
    +

    {entry.text}

    +

    + {new Date(entry.createdAt).toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +

    +
    +
  • + ))} +
+ )} +
+
+ + )}
); } - -interface CommandButtonProps { - icon: React.ReactNode; - label: string; - isActive: boolean; - onClick: () => void; -} - -function CommandButton({ icon, label, isActive, onClick }: CommandButtonProps) { - return ( - -
- {icon} -
- - {label} - -
- ); -} diff --git a/susconecta/components/features/pacientes/chat-widget.tsx b/susconecta/components/features/pacientes/chat-widget.tsx index e80dd4b..99e8ee9 100644 --- a/susconecta/components/features/pacientes/chat-widget.tsx +++ b/susconecta/components/features/pacientes/chat-widget.tsx @@ -1,58 +1,30 @@ "use client"; -import { useMemo, useState } from "react"; -import { Sparkles, MessageCircle, X, Send } from "lucide-react"; -import { Input } from "@/components/ui/input"; +import { useEffect, useMemo, useState } from "react"; +import { ArrowLeft, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"; -const cannedSuggestions = [ - "Como remarcar minha consulta?", - "Quais documentos preciso levar?", - "Quero falar com suporte humano", -]; - -const supportAvailability = { - title: "Equipe disponível", - description: "Seg–Sex das 08h às 18h", -}; - -interface ChatMessage { +interface HistoryEntry { id: string; - author: "assistant" | "user"; text: string; - timestamp: string; + createdAt: string; } export function ChatWidget() { - const [open, setOpen] = useState(false); - const [input, setInput] = useState(""); - const [messages, setMessages] = useState(() => [ - { - id: "welcome", - author: "assistant", - text: "Olá! Sou sua assistente virtual. Posso ajudar a acompanhar consultas, exames e suporte geral.", - timestamp: new Date().toISOString(), - }, - ]); + const [assistantOpen, setAssistantOpen] = useState(false); + const [history, setHistory] = useState([]); - const toggle = () => setOpen((prev) => !prev); + useEffect(() => { + if (!assistantOpen) return; - const handleSend = () => { - const trimmed = input.trim(); - if (!trimmed) return; - const now = new Date().toISOString(); - setMessages((prev) => [ - ...prev, - { id: `user-${now}`, author: "user", text: trimmed, timestamp: now }, - { - id: `assistant-${now}`, - author: "assistant", - text: "Recebi sua mensagem! Nossa equipe retornará em breve.", - timestamp: now, - }, - ]); - setInput(""); - }; + const original = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = original; + }; + }, [assistantOpen]); const gradientRing = useMemo( () => ( @@ -64,119 +36,72 @@ export function ChatWidget() { [] ); + const openAssistant = () => setAssistantOpen(true); + const closeAssistant = () => setAssistantOpen(false); + + const handleOpenDocuments = () => { + console.log("[ChatWidget] Abrindo fluxo de documentos"); + closeAssistant(); + }; + + const handleOpenChat = () => { + console.log("[ChatWidget] Encaminhando para chat humano"); + closeAssistant(); + }; + + const handleAddHistory = (entry: HistoryEntry) => { + setHistory((prev) => [...prev, entry]); + }; + + const handleClearHistory = () => { + setHistory([]); + }; + return ( -
- {open && ( + <> + {assistantOpen && (
-
-
- -
-
-

Assistente RiseUp

-

Pronta para ajudar no que você precisar

-
- -
- -
- {messages.map((message) => ( -
- - {message.author === "assistant" ? : } - -
- {message.text} -
-
- ))} + + Voltar +
- -
-
-

{supportAvailability.title}

-

{supportAvailability.description}

-
-
- {cannedSuggestions.map((suggestion) => ( - - ))} -
-
- setInput(event.target.value)} - placeholder="Escreva sua mensagem" - className="border-none px-0 text-sm focus-visible:ring-0" - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - handleSend(); - } - }} - /> - -
+
+
)} - -
+
+ +
+ ); } -- 2.47.2 From e927de1821f06015703f3f8a0ca434ddbbdfd0da Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Thu, 13 Nov 2025 13:54:26 -0300 Subject: [PATCH 03/12] fix(ia) adicionei um componente Voice Visualization --- .../features/pacientes/chat-widget.tsx | 95 ++++++++++++++++++- susconecta/package.json | 1 + susconecta/pnpm-lock.yaml | 8 ++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/susconecta/components/features/pacientes/chat-widget.tsx b/susconecta/components/features/pacientes/chat-widget.tsx index 99e8ee9..a270f5d 100644 --- a/susconecta/components/features/pacientes/chat-widget.tsx +++ b/susconecta/components/features/pacientes/chat-widget.tsx @@ -1,9 +1,12 @@ + + "use client"; import { useEffect, useMemo, useState } from "react"; -import { ArrowLeft, Sparkles } from "lucide-react"; +import { ArrowLeft, Mic, MicOff, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"; +import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb"; interface HistoryEntry { id: string; @@ -13,10 +16,13 @@ interface HistoryEntry { export function ChatWidget() { const [assistantOpen, setAssistantOpen] = useState(false); + const [realtimeOpen, setRealtimeOpen] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [voiceDetected, setVoiceDetected] = useState(false); const [history, setHistory] = useState([]); useEffect(() => { - if (!assistantOpen) return; + if (!assistantOpen && !realtimeOpen) return; const original = document.body.style.overflow; document.body.style.overflow = "hidden"; @@ -24,7 +30,7 @@ export function ChatWidget() { return () => { document.body.style.overflow = original; }; - }, [assistantOpen]); + }, [assistantOpen, realtimeOpen]); const gradientRing = useMemo( () => ( @@ -39,14 +45,33 @@ export function ChatWidget() { const openAssistant = () => setAssistantOpen(true); const closeAssistant = () => setAssistantOpen(false); + const openRealtime = () => setRealtimeOpen(true); + const closeRealtime = () => { + setRealtimeOpen(false); + setAssistantOpen(true); + setIsRecording(false); + setVoiceDetected(false); + }; + + const toggleRecording = () => { + setIsRecording((prev) => { + const next = !prev; + if (!next) { + setVoiceDetected(false); + } + return next; + }); + }; + const handleOpenDocuments = () => { console.log("[ChatWidget] Abrindo fluxo de documentos"); closeAssistant(); }; const handleOpenChat = () => { - console.log("[ChatWidget] Encaminhando para chat humano"); - closeAssistant(); + console.log("[ChatWidget] Encaminhando para chat em tempo real"); + setAssistantOpen(false); + openRealtime(); }; const handleAddHistory = (entry: HistoryEntry) => { @@ -87,6 +112,66 @@ export function ChatWidget() {
)} + {realtimeOpen && ( +
+
+ +
+ +
+
+
+ + {voiceDetected && ( + + Ouvindo… + + )} +
+ +
+ +

+ Ative a captura para falar com a equipe em tempo real. Assim que sua voz for detectada, a Zoe sinaliza visualmente e encaminha o atendimento. +

+
+
+
+
+ )} +
); - const handlePersistHistory = (text: string) => { - const entry: HistoryEntry = { - id: `hist-${Date.now()}`, - text, - createdAt: new Date().toISOString(), - }; - - if (onAddHistory) { - onAddHistory(entry); - } else { - setInternalHistory((prev) => [...prev, entry]); - } - setDrawerOpen(true); - }; - const handleClearHistory = () => { if (onClearHistory) { onClearHistory(); } else { setInternalHistory([]); } + setActiveSessionId(null); + setManualSelection(false); + setQuestion(""); + setHistoryPanelOpen(false); }; - const HistoryGlyph = () => ( - - - - - - - ); + const handleSelectSession = useCallback((sessionId: string) => { + setManualSelection(true); + setActiveSessionId(sessionId); + setHistoryPanelOpen(false); + }, []); + + const startNewConversation = useCallback(() => { + setManualSelection(true); + setActiveSessionId(null); + setQuestion(""); + setHistoryPanelOpen(false); + }, []); return (
-
-
- - - Zoe - -
-

Assistente Clínica Zoe

-

Olá, eu sou Zoe. Como posso ajudar hoje?

+ +
+
+
+ + Zoe + +
+

+ Assistente Clínica Zoe +

+ + {gradientGreeting && ( + + {gradientGreeting} + {plainGreeting ? " " : ""} + + )} + {plainGreeting && {plainGreeting}} + + +
+
+
+ {history.length > 0 && ( + + )} + {history.length > 0 && ( + + )} + + +
+ + Organizamos exames, orientações e tarefas assistenciais em um painel único para acelerar decisões clínicas. Utilize a Zoe para revisar resultados, registrar percepções e alinhar próximos passos com a equipe de saúde. +
- -
+ -
+ Suas informações permanecem criptografadas e seguras com a equipe Zoe. -
+ -
-
+ +
- Informativo importante + Informativo importante

- A Zoe é a assistente virtual da Clínica Zoe. Ela reúne informações sobre seus cuidados e orienta os próximos passos. - O atendimento é informativo e não substitui a avaliação de um profissional de saúde qualificado. + A Zoe acompanha toda a jornada clínica, consolida exames e registra orientações para que você tenha clareza em cada etapa do cuidado. + As respostas são informativas e complementam a avaliação de um profissional de saúde qualificado.

-

- Em situações de urgência, procure imediatamente o suporte médico presencial ou ligue para os serviços de emergência. +

+ Em situações de urgência, entre em contato com a equipe médica presencial ou acione os serviços de emergência da sua região.

-
+
-
+ -
- + {activeMessages.length > 0 ? ( + activeMessages.map((message) => ( +
+
+

{message.content}

+ + {formatTime(message.createdAt)} + +
+
+ )) + ) : ( +
+

Envie sua primeira mensagem

+

+ Compartilhe uma dúvida, exame ou orientação que deseja revisar. A Zoe registra o pedido e te retorna com um resumo organizado para a equipe de saúde. +

+
+ )} +
+ + +
+
+ +
setQuestion(event.target.value)} @@ -222,9 +573,9 @@ export function AIAssistantInterface({ } }} placeholder="Pergunte qualquer coisa para a Zoe" - className="border-none bg-transparent text-sm shadow-none focus-visible:ring-0" + className="w-full flex-1 border-none bg-transparent text-sm shadow-none focus-visible:ring-0" /> -
+
- {drawerOpen && ( - + )}
); } diff --git a/susconecta/components/features/pacientes/chat-widget.tsx b/susconecta/components/features/pacientes/chat-widget.tsx index a270f5d..f038ea2 100644 --- a/susconecta/components/features/pacientes/chat-widget.tsx +++ b/susconecta/components/features/pacientes/chat-widget.tsx @@ -5,21 +5,18 @@ import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, Mic, MicOff, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"; +import { + AIAssistantInterface, + ChatSession, +} from "@/components/ZoeIA/ai-assistant-interface"; import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb"; -interface HistoryEntry { - id: string; - text: string; - createdAt: string; -} - export function ChatWidget() { const [assistantOpen, setAssistantOpen] = useState(false); const [realtimeOpen, setRealtimeOpen] = useState(false); const [isRecording, setIsRecording] = useState(false); const [voiceDetected, setVoiceDetected] = useState(false); - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); useEffect(() => { if (!assistantOpen && !realtimeOpen) return; @@ -74,8 +71,16 @@ export function ChatWidget() { openRealtime(); }; - const handleAddHistory = (entry: HistoryEntry) => { - setHistory((prev) => [...prev, entry]); + const handleUpsertHistory = (session: ChatSession) => { + setHistory((previous) => { + const index = previous.findIndex((item) => item.id === session.id); + if (index >= 0) { + const updated = [...previous]; + updated[index] = session; + return updated; + } + return [...previous, session]; + }); }; const handleClearHistory = () => { @@ -105,7 +110,7 @@ export function ChatWidget() { onOpenDocuments={handleOpenDocuments} onOpenChat={handleOpenChat} history={history} - onAddHistory={handleAddHistory} + onAddHistory={handleUpsertHistory} onClearHistory={handleClearHistory} />
-- 2.47.2 From 82ac502553088929b6ae13472c87687d10dcb0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:48:34 -0300 Subject: [PATCH 06/12] fix-appoiments-save --- susconecta/app/laudos-editor/page.tsx | 10 +---- .../paciente/resultados/ResultadosClient.tsx | 39 +------------------ 2 files changed, 4 insertions(+), 45 deletions(-) diff --git a/susconecta/app/laudos-editor/page.tsx b/susconecta/app/laudos-editor/page.tsx index bff2180..52bd823 100644 --- a/susconecta/app/laudos-editor/page.tsx +++ b/susconecta/app/laudos-editor/page.tsx @@ -504,15 +504,9 @@ export default function LaudosEditorPage() {
)} - {/* Solicitante e Prazo */} + {/* Prazo */} {pacienteSelecionado && ( -
-
- - -
+
- {/* Próximos horários */} - {!isLoadingAgenda && ( -
-

Próximos horários disponíveis:

- {proximos3Horarios.length > 0 ? ( -
- {proximos3Horarios.map(slot => ( - - ))} -
- ) : ( -

Carregando horários...

- )} -
- )} - {/* Ações */}
))}
diff --git a/susconecta/app/paciente/resultados/ResultadosClient.tsx b/susconecta/app/paciente/resultados/ResultadosClient.tsx index 223f21a..d90f6c1 100644 --- a/susconecta/app/paciente/resultados/ResultadosClient.tsx +++ b/susconecta/app/paciente/resultados/ResultadosClient.tsx @@ -687,7 +687,7 @@ export default function ResultadosClient() { // Paginação local para a lista de médicos const [currentPage, setCurrentPage] = useState(1) - const [itemsPerPage, setItemsPerPage] = useState(100) + const [itemsPerPage, setItemsPerPage] = useState(5) // Resetar para página 1 quando o conjunto de profissionais (filtro) ou itemsPerPage mudar useEffect(() => { -- 2.47.2 From f885ee485df11447a0a06553209b585f5b1b7fac Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Thu, 13 Nov 2025 19:16:19 -0300 Subject: [PATCH 10/12] fixes moon icon regardless of operating system dark/light mode --- susconecta/components/ui/simple-theme-toggle.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/susconecta/components/ui/simple-theme-toggle.tsx b/susconecta/components/ui/simple-theme-toggle.tsx index 0c45ca0..ee11c24 100644 --- a/susconecta/components/ui/simple-theme-toggle.tsx +++ b/susconecta/components/ui/simple-theme-toggle.tsx @@ -17,10 +17,9 @@ export function SimpleThemeToggle() { variant="outline" size="icon" onClick={toggleTheme} - className="hover:bg-primary! hover:text-white! hover:border-primary! cursor-pointer shadow-sm! shadow-black/10! border-2! border-black! dark:shadow-none! dark:border-border! transition-colors" + className="hover:bg-primary! hover:text-white! hover:border-primary! cursor-pointer shadow-sm! shadow-black/10! border-2! border-black! dark:shadow-none! dark:border-border! transition-colors" > - - + Alternar tema ) -- 2.47.2 From db1774beda965e1ce08f0e1772200a3add4ac07a Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Thu, 13 Nov 2025 20:06:18 -0300 Subject: [PATCH 11/12] feat: add standardized select hover styles with blue-500 --- .../app/(main-routes)/consultas/page.tsx | 4 +-- .../app/(main-routes)/doutores/page.tsx | 10 +++--- .../app/(main-routes)/pacientes/page.tsx | 8 ++--- susconecta/app/globals.css | 15 +++++++++ susconecta/app/paciente/page.tsx | 8 ++--- susconecta/app/profissional/page.tsx | 32 +++++++++---------- .../components/features/dashboard/header.tsx | 4 +-- 7 files changed, 48 insertions(+), 33 deletions(-) diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index fe97473..0b99c88 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -519,7 +519,7 @@ export default function ConsultasPage() { {/* Linha 2: Selects responsivos */}
setItemsPerPage(Number(e.target.value))} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 2a52829..c0a1517 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -534,7 +534,7 @@ export default function DoutoresPage() { aria-label="Ordenar por" value={sortBy} onChange={(e) => setSortBy(e.target.value as any)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > @@ -546,7 +546,7 @@ export default function DoutoresPage() { aria-label="Filtrar por especialidade" value={specialtyFilter} onChange={(e) => setSpecialtyFilter(e.target.value)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > {specialtyOptions.map((sp) => ( @@ -558,7 +558,7 @@ export default function DoutoresPage() { aria-label="Filtrar por estado" value={stateFilter} onChange={(e) => { setStateFilter(e.target.value); setCityFilter(""); }} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > {stateOptions.map((uf) => ( @@ -570,7 +570,7 @@ export default function DoutoresPage() { aria-label="Filtrar por cidade" value={cityFilter} onChange={(e) => setCityFilter(e.target.value)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > {cityOptions.map((c) => ( @@ -764,7 +764,7 @@ export default function DoutoresPage() { setItemsPerPage(Number(e.target.value))} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" > diff --git a/susconecta/app/globals.css b/susconecta/app/globals.css index d41795d..ada3c18 100644 --- a/susconecta/app/globals.css +++ b/susconecta/app/globals.css @@ -167,3 +167,18 @@ button[aria-label="Close"], display: none !important; } +/* Classe padronizada de hover azul - consistente em todos os modos (claro/escuro SO e app) */ +.hover-primary-blue { + @apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-all duration-200; +} + +/* Hover simples para ícones e botões menores */ +.hover-primary-blue-soft { + @apply hover:bg-blue-500/10 hover:text-blue-500 transition-colors duration-200; +} + +/* Hover padronizado para selects de filtro */ +.select-hover-blue { + @apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-colors duration-200; +} + diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index 54f0317..dd9792b 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -693,10 +693,10 @@ export default function PacientePage() { const [tipoConsulta, setTipoConsulta] = useState<'teleconsulta' | 'presencial'>('teleconsulta') const [especialidade, setEspecialidade] = useState('cardiologia') const [localizacao, setLocalizacao] = useState('') - const hoverPrimaryClass = "transition duration-200 hover:bg-[#2563eb] hover:text-white focus-visible:ring-2 focus-visible:ring-[#2563eb]/60 active:scale-[0.97]" - const activeToggleClass = "w-full transition duration-200 focus-visible:ring-2 focus-visible:ring-[#2563eb]/60 active:scale-[0.97] bg-[#2563eb] text-white hover:bg-[#2563eb] hover:text-white" - const inactiveToggleClass = "w-full transition duration-200 bg-slate-50 text-[#2563eb] border border-[#2563eb]/30 hover:bg-slate-100 hover:text-[#2563eb] dark:bg-white/5 dark:text-white dark:hover:bg-white/10 dark:border-white/20" - const hoverPrimaryIconClass = "rounded-xl bg-white text-[#1e293b] border border-black/10 shadow-[0_2px_8px_rgba(0,0,0,0.03)] transition duration-200 hover:bg-[#2563eb] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563eb] dark:bg-slate-800 dark:text-slate-100 dark:border-white/10 dark:shadow-none dark:hover:bg-[#2563eb] dark:hover:text-white" + const hoverPrimaryClass = "hover-primary-blue focus-visible:ring-2 focus-visible:ring-blue-500/60 active:scale-[0.97]" + const activeToggleClass = "w-full transition duration-200 focus-visible:ring-2 focus-visible:ring-blue-500/60 active:scale-[0.97] bg-blue-500 text-white hover:bg-blue-500 hover:text-white" + const inactiveToggleClass = "w-full transition duration-200 bg-slate-50 text-blue-500 border border-blue-500/30 hover:bg-blue-50 hover:text-blue-500 dark:bg-white/5 dark:text-white dark:hover:bg-blue-500/20 dark:border-white/20" + const hoverPrimaryIconClass = "rounded-xl bg-white text-slate-900 border border-black/10 shadow-[0_2px_8px_rgba(0,0,0,0.03)] hover-primary-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-slate-800 dark:text-slate-100 dark:border-white/10 dark:shadow-none" const today = new Date(); today.setHours(0, 0, 0, 0); const selectedDate = new Date(currentDate); selectedDate.setHours(0, 0, 0, 0); const isSelectedDateToday = selectedDate.getTime() === today.getTime() diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx index 84e791f..e800161 100644 --- a/susconecta/app/profissional/page.tsx +++ b/susconecta/app/profissional/page.tsx @@ -809,13 +809,13 @@ const ProfissionalPage = () => {
{/* Navegação de Data - Responsiva */} -
+
@@ -826,7 +826,7 @@ const ProfissionalPage = () => { variant="outline" size="sm" onClick={() => navigateDate('next')} - className="p-1.5 sm:p-2 hover:bg-primary! hover:text-white! cursor-pointer transition-colors h-auto" + className="p-1.5 sm:p-2 hover-primary-blue cursor-pointer h-auto" > @@ -1027,7 +1027,7 @@ const ProfissionalPage = () => { variant={selectedRange === 'todos' ? 'default' : 'outline'} size="sm" onClick={() => setSelectedRange('todos')} - className="hover:bg-primary! hover:text-white! transition-colors" + className="hover-primary-blue" > Todos @@ -1035,7 +1035,7 @@ const ProfissionalPage = () => { variant={selectedRange === 'semana' ? 'default' : 'outline'} size="sm" onClick={() => setSelectedRange('semana')} - className="hover:bg-primary! hover:text-white! transition-colors" + className="hover-primary-blue" > Semana @@ -1043,7 +1043,7 @@ const ProfissionalPage = () => { variant={selectedRange === 'mes' ? 'default' : 'outline'} size="sm" onClick={() => setSelectedRange('mes')} - className="hover:bg-primary! hover:text-white! transition-colors" + className="hover-primary-blue" > Mês @@ -1204,7 +1204,7 @@ const ProfissionalPage = () => { -
@@ -1503,7 +1503,7 @@ const ProfissionalPage = () => { onClick={() => { router.push(`/laudos/${laudo.id}`); }} - className="flex items-center gap-1 hover:bg-primary! hover:text-white! transition-colors" + className="flex items-center gap-1 hover-primary-blue" > Ver Laudo @@ -2308,16 +2308,16 @@ const ProfissionalPage = () => { title="Cor da fonte" /> {/* Alinhamento */} - - - - + + + + {/* Listas */} - - + + {/* Recuo */} - - + + {/* Desfazer/Refazer */}
diff --git a/susconecta/components/features/dashboard/header.tsx b/susconecta/components/features/dashboard/header.tsx index 5b78f26..160de92 100644 --- a/susconecta/components/features/dashboard/header.tsx +++ b/susconecta/components/features/dashboard/header.tsx @@ -43,14 +43,14 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
- {/* Avatar Dropdown Simples */} -- 2.47.2 From 44f546a65ed0257de2912dfb606d80a6e3341850 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Thu, 13 Nov 2025 20:53:42 -0300 Subject: [PATCH 12/12] style(selects): standardizes blue hover on filters (admin/patients/doctors/appointments) --- susconecta/app/(main-routes)/consultas/page.tsx | 4 ++-- susconecta/app/(main-routes)/doutores/page.tsx | 10 +++++----- susconecta/app/(main-routes)/pacientes/page.tsx | 8 ++++---- susconecta/app/globals.css | 3 ++- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index 0b99c88..fe97473 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -519,7 +519,7 @@ export default function ConsultasPage() { {/* Linha 2: Selects responsivos */}
setItemsPerPage(Number(e.target.value))} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index c0a1517..2a52829 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -534,7 +534,7 @@ export default function DoutoresPage() { aria-label="Ordenar por" value={sortBy} onChange={(e) => setSortBy(e.target.value as any)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > @@ -546,7 +546,7 @@ export default function DoutoresPage() { aria-label="Filtrar por especialidade" value={specialtyFilter} onChange={(e) => setSpecialtyFilter(e.target.value)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > {specialtyOptions.map((sp) => ( @@ -558,7 +558,7 @@ export default function DoutoresPage() { aria-label="Filtrar por estado" value={stateFilter} onChange={(e) => { setStateFilter(e.target.value); setCityFilter(""); }} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > {stateOptions.map((uf) => ( @@ -570,7 +570,7 @@ export default function DoutoresPage() { aria-label="Filtrar por cidade" value={cityFilter} onChange={(e) => setCityFilter(e.target.value)} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > {cityOptions.map((c) => ( @@ -764,7 +764,7 @@ export default function DoutoresPage() { setItemsPerPage(Number(e.target.value))} - className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary select-hover-blue cursor-pointer" + className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer" > diff --git a/susconecta/app/globals.css b/susconecta/app/globals.css index ada3c18..23a3530 100644 --- a/susconecta/app/globals.css +++ b/susconecta/app/globals.css @@ -177,8 +177,9 @@ button[aria-label="Close"], @apply hover:bg-blue-500/10 hover:text-blue-500 transition-colors duration-200; } -/* Hover padronizado para selects de filtro */ +/* Hover padronizado para selects de filtro - apenas ao passar o mouse */ .select-hover-blue { + background-color: transparent; @apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-colors duration-200; } -- 2.47.2