Compare commits

...

30 Commits

Author SHA1 Message Date
c56cd9ff63 Merge pull request 'feat(api): add doctors and patients API integration' (#25) from feature/api-med-pac into develop
Reviewed-on: #25
2025-09-30 17:01:36 +00:00
84cb4c36eb feat(api): add doctors and patients API integration 2025-09-30 13:57:43 -03:00
56dd05c963 Merge pull request 'integrando os endpoints de login e logout' (#24) from feature/add-authentication-api into develop
Reviewed-on: #24
2025-09-28 18:55:00 +00:00
João Gustavo
e4afaa5743 removing-test-pages 2025-09-28 04:17:18 -03:00
João Gustavo
a6ae27876e add-login-and-logout-endpoints 2025-09-28 04:10:40 -03:00
e389b0894e Merge pull request 'feature/consultations' (#22) from feature/consultations into develop
Reviewed-on: #22
2025-09-25 20:36:25 +00:00
8bd4344670 chore(header): standardize company name to MEDIConecta 2025-09-25 17:01:32 -03:00
956a8ff016 fix(pages): Fix imports and type errors in agenda and patients - Fixes the form import in the scheduling page. - Adds optional chaining (?.) for safe accessto the patient's address. 2025-09-25 14:58:34 -03:00
92b598b14a fix(sidebar): resolve wrong navigation paths in sidebar menu 2025-09-25 13:40:01 -03:00
9cd35a0cc5 Merge pull request 'feature/settings' (#20) from feature/settings into develop
Reviewed-on: #20
2025-09-25 16:32:50 +00:00
ca7ab7a0fa merge: resolvidos conflitos entre feature/settings e develop (sidebar e package-lock.json) 2025-09-25 13:22:03 -03:00
67e52aa21f Merge branch 'develop' into feature/settings 2025-09-25 13:15:21 -03:00
5030ae38d0 " " 2025-09-25 11:10:20 -03:00
3c9bb1de4d feat(sidebar): remove medical record (prontuário) menu item from sidebar 2025-09-25 10:50:08 -03:00
de0d5b41a9 Merge pull request 'feature/consultations' (#17) from feature/consultations into develop
Reviewed-on: #17
2025-09-25 13:07:01 +00:00
23fad33ef9 feat: implement settings module 2025-09-25 10:05:33 -03:00
e17e709c01 Merge branch 'develop' into feature/consultations 2025-09-25 02:26:53 -03:00
f14643fa6a feat(ui): implements a visualization mode and standardizes the layout for patients, physicians, and appointments 2025-09-25 02:09:47 -03:00
2399fdfac9 fix(consultas): corrects the name of the form component onthe query page 2025-09-24 23:03:40 -03:00
31b02fdf2d Merge pull request 'feature/report' (#16) from feature/report into develop
Reviewed-on: #16
2025-09-25 00:27:27 +00:00
f8f32a9db7 feat(relatorios): adiciona gráfico financeiro com dados fictícios 2025-09-24 20:52:01 -03:00
19a9905b0c feat: adicionar página de relatórios 2025-09-24 10:48:28 -03:00
72a23cba69 Ignorando pasta riseup-squad20 2025-09-24 09:23:17 -03:00
d69e8408fe Ignorando pasta susconecta/riseup-squad20 2025-09-24 09:22:27 -03:00
b50b429d16 Removendo repositório embutido riseup-squad20 do versionamento 2025-09-24 09:20:36 -03:00
616853220b feat(consultas): implements full editing with reusable form
- Refactors the scheduling form, extracting it from the /agenda page into a new reusable component at components/forms/appointment-form.tsx                             - The appointment creation page (/agenda) now uses the new form component.          - The consultations page (/consultas) now implements in-place editing, rendering the same reusable form when clicking
- The appointment creation page (/agenda) now uses the new form component.
- The consultations page (/consultas) now implements in-place editing, rendering the same reusable form when clicking "Edit", pre-filled with the consultation data.
2025-09-24 03:32:38 -03:00
ab422746c8 feat(consultas, deps): Creates query page and adds dependencies
- Creates new query management page at /queries with view and delete functionality (frontend).
- Adds react-quill and react-signature-canvas libraries.
- Moves patient and doctor pages out of /dashboard nesting.
- Updates the sidebar to reflect the new routes, fixing 404 errors.
2025-09-24 03:05:15 -03:00
ba8b7881a4 Merge pull request 'adicionar login e informações do perfil' (#15) from feature/add-login-screen into develop
Reviewed-on: #15
2025-09-23 17:19:16 +00:00
João Gustavo
af7de1dd0c add login-screen 2025-09-23 01:23:41 -03:00
c36a16be06 feat: ajustes na seção de laudos, cpf, imagem e assinatura digital 2025-09-22 22:33:38 -03:00
49 changed files with 9133 additions and 865 deletions

45
et --hard 23fad33 Normal file
View File

@ -0,0 +1,45 @@
a0d527c (HEAD -> feature/settings) ajuste no package.json da raiz
23fad33 (origin/feature/settings) feat: implement settings module
c36a16b (develop) feat: ajustes na seção de laudos, cpf, imagem e assinatura digital
913fd6a (origin/feature/doctor-laudo, feature/api-medic) Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
791d31a (origin/feature/api-medicos, feature/api-medicos) feat(api): implementação e integração das APIs de médicos
e53d7fb (feature/crud-medi-api) Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
7aadcef Fix: folder organization
c6b18b7 Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling
945c6ea fix: Calendar and sidebar
dfb70c6 Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
30b5609 feat: adds new fields and cards to the physician registry
9dfba10 Merge branch 'develop' into feature/scheduling
f435ade Ajuste no .gitignore
9c7ce7d Finalizando merge da branch develop com origin/develop
76feb4b feat:implements CRUD for doctors
70c67e4 Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
ba64fde add: new doctor page
a7c9c90 chore: update components config
a5d89b3 Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
0d416ca (origin/feature/image-doctor) resolvendo erro de imagens
e405cc5 WIP: alterações locais
bb4cc38 Ajustes no .gitignore
953a4e7 WIP: alterações locais
debc92d chore(calendar): adjust naming for calendar component consistency
ae637c4 fix/errors-medical-page
df530f7 Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
94839cc (origin/feature/crud-medico, feature/crud-medico) Adicionando calendario interativo do medico
93a4389 fix(merge): prefer feature versions (layout.tsx, package-lock.json)
f2db866 (feature/patient-register) fix(merge): resolve conflicts between develop and feature/patient-register
cdd44da chore: save changes before switching branch
b2a9ea0 (origin/feature/patient-register) feat(api): add and wire all mock endpoints
a1ba4e5 Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
40f05ca (origin/feature/scheduling) ajeitando erro dos botões
a9d093e adicionando agendamento-incompleto
6ca8524 Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
7385e64 feat: add medical page
a44e9bc Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register
372383f feat: connect patient registration form to create patient API
3cce8a9 fix: fix ref error in actions menu
91c84b6 fix: secure setting of onOpenChange on the patient form
8258fac feat: implement patient recorder
20d070e (origin/feature/patient-list, feature/patient-list) chore: remove Website folderfrom repository
0ba1590 feat: add initial project files and patient list
631f7f2 (origin/feature/cadastro-pacientes, origin/developer, feature/cadastro-pacientes) feat: add initial structure
6414f69 (origin/main, origin/HEAD) Initial commit

51
package-lock.json generated
View File

@ -8,7 +8,8 @@
"@headlessui/react": "^2.2.7", "@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react-big-calendar": "^1.19.4" "react-big-calendar": "^1.19.4",
"react-signature-canvas": "^1.1.0-alpha.2"
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
@ -266,6 +267,12 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/@types/signature_pad": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
"license": "MIT"
},
"node_modules/@types/warning": { "node_modules/@types/warning": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
@ -520,6 +527,36 @@
"react-dom": ">=16.3.0" "react-dom": ">=16.3.0"
} }
}, },
"node_modules/react-signature-canvas": {
"version": "1.1.0-alpha.2",
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
"integrity": "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.17.9",
"@types/signature_pad": "^2.3.0",
"signature_pad": "^2.3.2",
"trim-canvas": "^0.1.0"
},
"funding": {
"url": "https://github.com/sponsors/agilgur5"
},
"peerDependencies": {
"@types/prop-types": "^15.7.3",
"@types/react": "0.14 - 19",
"prop-types": "^15.5.8",
"react": "0.14 - 19",
"react-dom": "0.14 - 19"
},
"peerDependenciesMeta": {
"@types/prop-types": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@ -527,12 +564,24 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/signature_pad": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
"license": "MIT"
},
"node_modules/tabbable": { "node_modules/tabbable": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/trim-canvas": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
"license": "Apache-2.0"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@ -3,6 +3,7 @@
"@headlessui/react": "^2.2.7", "@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react-big-calendar": "^1.19.4" "react-big-calendar": "^1.19.4",
"react-signature-canvas": "^1.1.0-alpha.2"
} }
} }

View File

@ -24,4 +24,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.tsriseup-squad20/
susconecta/riseup-squad20/
riseup-squad20/

View File

@ -0,0 +1,34 @@
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function AgendaConfigPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Configurações da Agenda</h1>
<Card>
<CardHeader>
<CardTitle>Tempo padrão de consulta</CardTitle>
</CardHeader>
<CardContent>
<select className="border rounded p-2">
<option>15 minutos</option>
<option>30 minutos</option>
<option>1 hora</option>
</select>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Horário de funcionamento</CardTitle>
</CardHeader>
<CardContent>
<input type="time" className="border rounded p-2 mr-2" /> até
<input type="time" className="border rounded p-2 ml-2" />
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,36 @@
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function ComunicacaoConfigPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Configurações de Comunicação</h1>
<Card>
<CardHeader>
<CardTitle>Modelo de Lembrete</CardTitle>
</CardHeader>
<CardContent>
<textarea
className="w-full border rounded p-2"
placeholder="Exemplo: Olá {nome}, sua consulta está marcada para {data} às {hora}."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Frequência de Lembretes</CardTitle>
</CardHeader>
<CardContent>
<select className="border rounded p-2">
<option>24 horas antes</option>
<option>4 horas antes</option>
<option>1 hora antes</option>
</select>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,25 @@
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function NotificacoesConfigPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Configurações de Notificações</h1>
<Card>
<CardHeader>
<CardTitle>Alertas Internos</CardTitle>
</CardHeader>
<CardContent>
<label className="flex items-center space-x-2">
<input type="checkbox" className="h-4 w-4" /> <span>Notificar quando consulta for cancelada</span>
</label>
<label className="flex items-center space-x-2 mt-2">
<input type="checkbox" className="h-4 w-4" /> <span>Notificar quando novo paciente for cadastrado</span>
</label>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,77 @@
"use client"
import Link from "next/link"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import {
Calendar,
MessageSquare,
Bell,
Users,
ShieldCheck,
} from "lucide-react"
export default function ConfiguracaoPage() {
const items = [
{
title: "Agenda",
desc: "Defina horários e bloqueios",
href: "/dashboard/configuracao/agenda",
icon: Calendar,
},
{
title: "Comunicação",
desc: "Gerencie mensagens automáticas",
href: "/dashboard/configuracao/comunicacao",
icon: MessageSquare,
},
{
title: "Notificações",
desc: "Configure alertas internos",
href: "/dashboard/configuracao/notificacoes",
icon: Bell,
},
{
title: "Usuários",
desc: "Controle acessos e permissões",
href: "/dashboard/configuracao/usuarios",
icon: Users,
},
{
title: "Segurança",
desc: "Senhas, privacidade e LGPD",
href: "/dashboard/configuracao/seguranca",
icon: ShieldCheck,
},
]
return (
<div className="p-6 space-y-6">
{/* título */}
<h1 className="text-2xl font-bold">Configurações</h1>
{/* introdução */}
<p className="text-gray-600">
Ajuste os principais parâmetros do sistema. Escolha uma das seções abaixo
para configurar horários, mensagens, notificações internas, permissões de usuários
e regras de segurança da clínica.
</p>
{/* grid de cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => (
<Link key={item.title} href={item.href}>
<Card className="cursor-pointer hover:shadow-md transition">
<CardHeader className="flex flex-row items-center gap-2">
<item.icon className="w-5 h-5 text-primary" />
<CardTitle>{item.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{item.desc}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function SegurancaConfigPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Configurações de Segurança</h1>
<Card>
<CardHeader>
<CardTitle>Alterar Senha</CardTitle>
</CardHeader>
<CardContent>
<input type="password" placeholder="Senha atual" className="w-full border rounded p-2 mb-2" />
<input type="password" placeholder="Nova senha" className="w-full border rounded p-2 mb-2" />
<input type="password" placeholder="Confirmar nova senha" className="w-full border rounded p-2" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Política de Dados (LGPD)</CardTitle>
</CardHeader>
<CardContent>
<label className="flex items-center space-x-2">
<input type="checkbox" className="h-4 w-4" /> <span>Solicitar consentimento do paciente no cadastro</span>
</label>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,37 @@
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function UsuariosConfigPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Gerenciamento de Usuários</h1>
<Card>
<CardHeader>
<CardTitle>Usuários da Clínica</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full border">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left">Nome</th>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Permissão</th>
<th className="p-2">Ações</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-2">Maria Silva</td>
<td className="p-2">maria@clinica.com</td>
<td className="p-2">Secretária</td>
<td className="p-2">[Editar] [Remover]</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,362 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
MoreHorizontal,
PlusCircle,
Search,
Eye,
Edit,
Trash2,
ArrowLeft,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { mockAppointments, mockProfessionals } from "@/lib/mocks/appointment-mocks";
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
const formatDate = (date: string | Date) => {
if (!date) return "";
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const capitalize = (s: string) => {
if (typeof s !== 'string' || s.length === 0) return '';
return s.charAt(0).toUpperCase() + s.slice(1);
};
export default function ConsultasPage() {
const [appointments, setAppointments] = useState(mockAppointments);
const [showForm, setShowForm] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
const [viewingAppointment, setViewingAppointment] = useState<any | null>(null);
const mapAppointmentToFormData = (appointment: any) => {
const professional = mockProfessionals.find(p => p.id === appointment.professional);
const appointmentDate = new Date(appointment.time);
return {
id: appointment.id,
patientName: appointment.patient,
professionalName: professional ? professional.name : '',
appointmentDate: appointmentDate.toISOString().split('T')[0],
startTime: appointmentDate.toTimeString().split(' ')[0].substring(0, 5),
endTime: new Date(appointmentDate.getTime() + appointment.duration * 60000).toTimeString().split(' ')[0].substring(0, 5),
status: appointment.status,
appointmentType: appointment.type,
notes: appointment.notes,
cpf: '',
rg: '',
birthDate: '',
phoneCode: '+55',
phoneNumber: '',
email: '',
unit: 'nei',
};
};
const handleDelete = (appointmentId: string) => {
if (window.confirm("Tem certeza que deseja excluir esta consulta?")) {
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
}
};
const handleEdit = (appointment: any) => {
const formData = mapAppointmentToFormData(appointment);
setEditingAppointment(formData);
setShowForm(true);
};
const handleView = (appointment: any) => {
setViewingAppointment(appointment);
};
const handleCancel = () => {
setEditingAppointment(null);
setShowForm(false);
};
const handleSave = (formData: any) => {
const updatedAppointment = {
id: formData.id,
patient: formData.patientName,
time: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
duration: 30,
type: formData.appointmentType as any,
status: formData.status as any,
professional: appointments.find(a => a.id === formData.id)?.professional || '',
notes: formData.notes,
};
setAppointments(prev =>
prev.map(a => a.id === updatedAppointment.id ? updatedAppointment : a)
);
handleCancel();
};
if (showForm && editingAppointment) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
</div>
<CalendarRegistrationForm
initialData={editingAppointment}
onSave={handleSave}
onCancel={handleCancel}
/>
</div>
)
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">Gerenciamento de Consultas</h1>
<p className="text-muted-foreground">Visualize, filtre e gerencie todas as consultas da clínica.</p>
</div>
<div className="flex items-center gap-2">
<Link href="/agenda">
<Button size="sm" className="h-8 gap-1">
<PlusCircle className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Agendar Nova Consulta
</span>
</Button>
</Link>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Consultas Agendadas</CardTitle>
<CardDescription>
Visualize, filtre e gerencie todas as consultas da clínica.
</CardDescription>
<div className="pt-4 flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[250px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por..."
className="pl-8 w-full"
/>
</div>
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrar por status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="confirmed">Confirmada</SelectItem>
<SelectItem value="pending">Pendente</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
<Input type="date" className="w-[180px]" />
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Paciente</TableHead>
<TableHead>Médico</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data e Hora</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{appointments.map((appointment) => {
const professional = mockProfessionals.find(
(p) => p.id === appointment.professional
);
return (
<TableRow key={appointment.id}>
<TableCell className="font-medium">
{appointment.patient}
</TableCell>
<TableCell>
{professional ? professional.name : "Não encontrado"}
</TableCell>
<TableCell>
<Badge
variant={
appointment.status === "confirmed"
? "default"
: appointment.status === "pending"
? "secondary"
: "destructive"
}
className={
appointment.status === "confirmed" ? "bg-green-600" : ""
}
>
{capitalize(appointment.status)}
</Badge>
</TableCell>
<TableCell>{formatDate(appointment.time)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
<span className="sr-only">Abrir menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleView(appointment)}
>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(appointment.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{viewingAppointment && (
<Dialog open={!!viewingAppointment} onOpenChange={() => setViewingAppointment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detalhes da Consulta</DialogTitle>
<DialogDescription>
Informações detalhadas da consulta de {viewingAppointment?.patient}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Paciente
</Label>
<span className="col-span-3">{viewingAppointment?.patient}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
Médico
</Label>
<span className="col-span-3">
{mockProfessionals.find(p => p.id === viewingAppointment?.professional)?.name || "Não encontrado"}
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
Data e Hora
</Label>
<span className="col-span-3">{viewingAppointment?.time ? formatDate(viewingAppointment.time) : ''}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
Status
</Label>
<span className="col-span-3">
<Badge
variant={
viewingAppointment?.status === "confirmed"
? "default"
: viewingAppointment?.status === "pending"
? "secondary"
: "destructive"
}
className={
viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""
}
>
{capitalize(viewingAppointment?.status || '')}
</Badge>
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
Tipo
</Label>
<span className="col-span-3">{capitalize(viewingAppointment?.type || '')}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
Observações
</Label>
<span className="col-span-3">{viewingAppointment?.notes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingAppointment(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import { Button } from "@/components/ui/button";
import { FileDown } from "lucide-react";
import jsPDF from "jspdf";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
export default function RelatoriosPage() {
// Dados fictícios para o gráfico financeiro
const financeiro = [
{ mes: "Jan", faturamento: 35000, despesas: 12000 },
{ mes: "Fev", faturamento: 29000, despesas: 15000 },
{ mes: "Mar", faturamento: 42000, despesas: 18000 },
{ mes: "Abr", faturamento: 38000, despesas: 14000 },
{ mes: "Mai", faturamento: 45000, despesas: 20000 },
{ mes: "Jun", faturamento: 41000, despesas: 17000 },
];
// ============================
// PASSO 3 - Funções de exportar
// ============================
const exportConsultasPDF = () => {
const doc = new jsPDF();
doc.text("Relatório de Consultas", 10, 10);
doc.text("Resumo das consultas realizadas.", 10, 20);
doc.save("relatorio-consultas.pdf");
};
const exportPacientesPDF = () => {
const doc = new jsPDF();
doc.text("Relatório de Pacientes", 10, 10);
doc.text("Informações gerais dos pacientes cadastrados.", 10, 20);
doc.save("relatorio-pacientes.pdf");
};
const exportFinanceiroPDF = () => {
const doc = new jsPDF();
doc.text("Relatório Financeiro", 10, 10);
doc.text("Receitas e despesas da clínica.", 10, 20);
doc.save("relatorio-financeiro.pdf");
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Relatórios</h1>
<div className="grid grid-cols-3 gap-6">
{/* Card Consultas */}
<div className="p-4 border rounded-lg shadow">
<h2 className="font-semibold text-lg">Relatório de Consultas</h2>
<p className="text-sm text-gray-500">Resumo das consultas realizadas.</p>
{/* PASSO 4 - Botão chama a função */}
<Button onClick={exportConsultasPDF} className="mt-4">
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
</Button>
</div>
{/* Card Pacientes */}
<div className="p-4 border rounded-lg shadow">
<h2 className="font-semibold text-lg">Relatório de Pacientes</h2>
<p className="text-sm text-gray-500">Informações gerais dos pacientes cadastrados.</p>
<Button onClick={exportPacientesPDF} className="mt-4">
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
</Button>
</div>
{/* Card Financeiro com gráfico */}
<div className="p-4 border rounded-lg shadow col-span-3 md:col-span-3">
<h2 className="font-semibold text-lg mb-2">Relatório Financeiro</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={financeiro} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mes" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="faturamento" fill="#10b981" name="Faturamento" />
<Bar dataKey="despesas" fill="#ef4444" name="Despesas" />
</BarChart>
</ResponsiveContainer>
<Button onClick={exportFinanceiroPDF} className="mt-4">
<FileDown className="mr-2 h-4 w-4" /> Exportar PDF
</Button>
</div>
</div>
</div>
);
}

View File

@ -5,26 +5,59 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react"; import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form"; import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
// >>> IMPORTES DA API <<<
import { listarMedicos, excluirMedico, Medico } from "@/lib/api"; import { listarMedicos, excluirMedico, Medico } from "@/lib/api";
function normalizeMedico(m: any): Medico {
return {
id: String(m.id ?? m.uuid ?? ""),
nome: m.nome ?? m.full_name ?? "", // 👈 Supabase usa full_name
nome_social: m.nome_social ?? m.social_name ?? null,
cpf: m.cpf ?? "",
rg: m.rg ?? m.document_number ?? null,
sexo: m.sexo ?? m.sex ?? null,
data_nascimento: m.data_nascimento ?? m.birth_date ?? null,
telefone: m.telefone ?? m.phone_mobile ?? "",
celular: m.celular ?? m.phone2 ?? null,
contato_emergencia: m.contato_emergencia ?? null,
email: m.email ?? "",
crm: m.crm ?? "",
estado_crm: m.estado_crm ?? m.crm_state ?? null,
rqe: m.rqe ?? null,
formacao_academica: m.formacao_academica ?? [],
curriculo_url: m.curriculo_url ?? null,
especialidade: m.especialidade ?? m.specialty ?? "",
observacoes: m.observacoes ?? m.notes ?? null,
foto_url: m.foto_url ?? null,
tipo_vinculo: m.tipo_vinculo ?? null,
dados_bancarios: m.dados_bancarios ?? null,
agenda_horario: m.agenda_horario ?? null,
valor_consulta: m.valor_consulta ?? null,
};
}
export default function DoutoresPage() { export default function DoutoresPage() {
const [doctors, setDoctors] = useState<Medico[]>([]); const [doctors, setDoctors] = useState<Medico[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [viewingDoctor, setViewingDoctor] = useState<Medico | null>(null);
// Carrega da API
async function load() { async function load() {
setLoading(true); setLoading(true);
try { try {
const list = await listarMedicos({ limit: 50 }); const list = await listarMedicos({ limit: 50 });
setDoctors(list ?? []); setDoctors((list ?? []).map(normalizeMedico));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -50,27 +83,52 @@ export default function DoutoresPage() {
setShowForm(true); setShowForm(true);
} }
function handleEdit(id: string) { function handleEdit(id: string) {
setEditingId(id); setEditingId(id);
setShowForm(true); setShowForm(true);
} }
// Excluir via API e recarregar function handleView(doctor: Medico) {
setViewingDoctor(doctor);
}
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Excluir este médico?")) return; if (!confirm("Excluir este médico?")) return;
await excluirMedico(id); await excluirMedico(id);
await load(); await load();
} }
// Após salvar/criar/editar no form, fecha e recarrega
async function handleSaved() { function handleSaved(savedDoctor?: Medico) {
setShowForm(false); setShowForm(false);
await load();
if (savedDoctor) {
const normalized = normalizeMedico(savedDoctor);
setDoctors((prev) => {
const i = prev.findIndex((d) => String(d.id) === String(normalized.id));
if (i < 0) {
// Novo médico → adiciona no topo
return [normalized, ...prev];
} else {
// Médico editado → substitui na lista
const clone = [...prev];
clone[i] = normalized;
return clone;
}
});
} else {
// fallback → recarrega tudo
load();
} }
}
if (showForm) { if (showForm) {
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}> <Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -90,7 +148,7 @@ export default function DoutoresPage() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-6">
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="text-2xl font-bold">Médicos</h1> <h1 className="text-2xl font-bold">Médicos</h1>
@ -155,7 +213,7 @@ export default function DoutoresPage() {
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => alert(JSON.stringify(doctor, null, 2))}> <DropdownMenuItem onClick={() => handleView(doctor)}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Ver Ver
</DropdownMenuItem> </DropdownMenuItem>
@ -182,6 +240,47 @@ export default function DoutoresPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{viewingDoctor && (
<Dialog open={!!viewingDoctor} onOpenChange={() => setViewingDoctor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detalhes do Médico</DialogTitle>
<DialogDescription>
Informações detalhadas de {viewingDoctor?.nome}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Nome</Label>
<span className="col-span-3 font-medium">{viewingDoctor?.nome}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Especialidade</Label>
<span className="col-span-3">
<Badge variant="outline">{viewingDoctor?.especialidade}</Badge>
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">CRM</Label>
<span className="col-span-3">{viewingDoctor?.crm}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Email</Label>
<span className="col-span-3">{viewingDoctor?.email}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Telefone</Label>
<span className="col-span-3">{viewingDoctor?.telefone}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingDoctor(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Mostrando {filtered.length} de {doctors.length} Mostrando {filtered.length} de {doctors.length}
</div> </div>

View File

@ -1,4 +1,5 @@
import type React from "react"; import type React from "react";
import ProtectedRoute from "@/components/ProtectedRoute";
import { Sidebar } from "@/components/dashboard/sidebar"; import { Sidebar } from "@/components/dashboard/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { PagesHeader } from "@/components/dashboard/header"; import { PagesHeader } from "@/components/dashboard/header";
@ -8,15 +9,19 @@ export default function MainRoutesLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
console.log('[MAIN-ROUTES-LAYOUT] Layout do administrador carregado')
return ( return (
<div className="min-h-screen bg-background flex"> <ProtectedRoute requiredUserType={["administrador"]}>
<SidebarProvider> <div className="min-h-screen bg-background flex">
<Sidebar /> <SidebarProvider>
<main className="flex-1"> <Sidebar />
<PagesHeader /> <main className="flex-1">
{children} <PagesHeader />
</main> {children}
</SidebarProvider> </main>
</div> </SidebarProvider>
</div>
</ProtectedRoute>
); );
} }

View File

@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react"; import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api"; import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
@ -15,30 +17,31 @@ import { PatientRegistrationForm } from "@/components/forms/patient-registration
function normalizePaciente(p: any): Paciente { function normalizePaciente(p: any): Paciente {
const endereco: Endereco = { const endereco: Endereco = {
cep: p.endereco?.cep ?? p.cep ?? "", cep: p.endereco?.cep ?? p.cep ?? "",
logradouro: p.endereco?.logradouro ?? p.logradouro ?? "", logradouro: p.endereco?.logradouro ?? p.street ?? "",
numero: p.endereco?.numero ?? p.numero ?? "", numero: p.endereco?.numero ?? p.number ?? "",
complemento: p.endereco?.complemento ?? p.complemento ?? "", complemento: p.endereco?.complemento ?? p.complement ?? "",
bairro: p.endereco?.bairro ?? p.bairro ?? "", bairro: p.endereco?.bairro ?? p.neighborhood ?? "",
cidade: p.endereco?.cidade ?? p.cidade ?? "", cidade: p.endereco?.cidade ?? p.city ?? "",
estado: p.endereco?.estado ?? p.estado ?? "", estado: p.endereco?.estado ?? p.state ?? "",
}; };
return { return {
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""), id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
nome: p.nome ?? "", nome: p.full_name ?? "", // 👈 troca nome → full_name
nome_social: p.nome_social ?? null, nome_social: p.social_name ?? null, // 👈 Supabase usa social_name
cpf: p.cpf ?? "", cpf: p.cpf ?? "",
rg: p.rg ?? null, rg: p.rg ?? p.document_number ?? null, // 👈 às vezes vem como document_number
sexo: p.sexo ?? null, sexo: p.sexo ?? p.sex ?? null, // 👈 Supabase usa sex
data_nascimento: p.data_nascimento ?? null, data_nascimento: p.data_nascimento ?? p.birth_date ?? null,
telefone: p.telefone ?? "", telefone: p.telefone ?? p.phone_mobile ?? "",
email: p.email ?? "", email: p.email ?? "",
endereco, endereco,
observacoes: p.observacoes ?? null, observacoes: p.observacoes ?? p.notes ?? null,
foto_url: p.foto_url ?? null, foto_url: p.foto_url ?? null,
}; };
} }
export default function PacientesPage() { export default function PacientesPage() {
const [patients, setPatients] = useState<Paciente[]>([]); const [patients, setPatients] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -47,6 +50,7 @@ export default function PacientesPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [viewingPatient, setViewingPatient] = useState<Paciente | null>(null);
async function loadAll() { async function loadAll() {
try { try {
@ -88,6 +92,10 @@ export default function PacientesPage() {
setShowForm(true); setShowForm(true);
} }
function handleView(patient: Paciente) {
setViewingPatient(patient);
}
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Excluir este paciente?")) return; if (!confirm("Excluir este paciente?")) return;
try { try {
@ -161,7 +169,6 @@ export default function PacientesPage() {
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{}
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="text-2xl font-bold">Pacientes</h1> <h1 className="text-2xl font-bold">Pacientes</h1>
@ -217,7 +224,7 @@ export default function PacientesPage() {
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => alert(JSON.stringify(p, null, 2))}> <DropdownMenuItem onClick={() => handleView(p)}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Ver Ver
</DropdownMenuItem> </DropdownMenuItem>
@ -245,6 +252,46 @@ export default function PacientesPage() {
</Table> </Table>
</div> </div>
{viewingPatient && (
<Dialog open={!!viewingPatient} onOpenChange={() => setViewingPatient(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Detalhes do Paciente</DialogTitle>
<DialogDescription>
Informações detalhadas de {viewingPatient.nome}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Nome</Label>
<span className="col-span-3 font-medium">{viewingPatient.nome}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">CPF</Label>
<span className="col-span-3">{viewingPatient.cpf}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Telefone</Label>
<span className="col-span-3">{viewingPatient.telefone}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Endereço</Label>
<span className="col-span-3">
{`${viewingPatient.endereco?.logradouro || ''}, ${viewingPatient.endereco?.numero || ''} - ${viewingPatient.endereco?.bairro || ''}, ${viewingPatient.endereco?.cidade || ''} - ${viewingPatient.endereco?.estado || ''}`}
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Observações</Label>
<span className="col-span-3">{viewingPatient.observacoes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingPatient(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div> <div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
</div> </div>
); );

View File

@ -0,0 +1,22 @@
"use client"
export default function AppointmentForm() {
return (
<form className="p-4 border rounded space-y-4">
<div>
<label className="block text-sm font-medium">Paciente</label>
<input type="text" className="mt-1 w-full border p-2 rounded" />
</div>
<div>
<label className="block text-sm font-medium">Data</label>
<input type="date" className="mt-1 w-full border p-2 rounded" />
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Salvar
</button>
</form>
)
}

View File

@ -1,372 +1,33 @@
"use client"; "use client";
import { useState } from "react"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Calendar } from "lucide-react";
import {
RotateCcw,
Accessibility,
Volume2,
Flame,
Settings,
Clipboard,
Search,
ChevronDown,
Upload,
FileDown,
Tag,
Save,
} from "lucide-react";
import HeaderAgenda from "@/components/agenda/HeaderAgenda"; import HeaderAgenda from "@/components/agenda/HeaderAgenda";
import FooterAgenda from "@/components/agenda/FooterAgenda"; import FooterAgenda from "@/components/agenda/FooterAgenda";
export default function NovoAgendamentoPage() { export default function NovoAgendamentoPage() {
const [bloqueio, setBloqueio] = useState(false); const router = useRouter();
const handleSave = (data: any) => {
console.log("Salvando novo agendamento...", data);
alert("Novo agendamento salvo (simulado)!");
router.push("/consultas");
};
const handleCancel = () => {
router.back();
};
return ( return (
// ====== WRAPPER COM ESPAÇAMENTO GERAL ======
<div className="min-h-screen flex flex-col bg-white"> <div className="min-h-screen flex flex-col bg-white">
{/* HEADER fora do <main>, usando o MESMO container do footer */}
<HeaderAgenda /> <HeaderAgenda />
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8">
{/* Conteúdo */} <CalendarRegistrationForm
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8 space-y-8"> onSave={handleSave}
{/* ==== INFORMAÇÕES DO PACIENTE — layout idêntico ao print ==== */} onCancel={handleCancel}
<div className="border rounded-md p-6 space-y-4 bg-white"> initialData={{}}
<h2 className="font-medium">Informações do paciente</h2> />
{/* grade principal: 12 colunas para controlar as larguras */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
{/* ===== Linha 1 ===== */}
<div className="md:col-span-6">
<Label>Nome *</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Digite o nome do paciente"
className="h-10 pl-8"
/>
</div>
</div>
<div className="md:col-span-3">
<Label>CPF do paciente</Label>
<Input placeholder="Número do CPF" className="h-10" />
</div>
<div className="md:col-span-3">
<Label>RG</Label>
<Input placeholder="Número do RG" className="h-10" />
</div>
{/* ===== Linha 2 ===== */}
{/* 1ª coluna (span 6) com sub-grid: Data (5 col) + Telefone (7 col) */}
<div className="md:col-span-6">
<div className="grid grid-cols-12 gap-3">
<div className="col-span-5">
<Label>Data de nascimento *</Label>
<Input type="date" className="h-10" />
</div>
<div className="col-span-7">
<Label>Telefone</Label>
<div className="grid grid-cols-[86px_1fr] gap-2">
<select className="h-10 rounded-md border border-input bg-background px-2 text-[13px]">
<option value="+55">+55</option>
<option value="+351">+351</option>
<option value="+1">+1</option>
</select>
<Input placeholder="(99) 99999-9999" className="h-10" />
</div>
</div>
</div>
</div>
{/* 2ª coluna da linha 2: E-mail (span 6) */}
<div className="md:col-span-6">
<Label>E-mail</Label>
<Input
type="email"
placeholder="email@exemplo.com"
className="h-10"
/>
</div>
{/* ===== Linha 3 ===== */}
<div className="md:col-span-6">
<Label>Convênio</Label>
<div className="relative">
<select
defaultValue="particular"
className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none"
>
<option value="particular">Particular</option>
<option value="plano-a">Plano A</option>
<option value="plano-b">Plano B</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<div className="md:col-span-6 grid grid-cols-2 gap-4">
<div>
<Label>Matrícula</Label>
<Input defaultValue="000000000" className="h-10" />
</div>
<div>
<Label>Validade</Label>
<Input placeholder="00/0000" className="h-10" />
</div>
</div>
</div>
{/* link Informações adicionais */}
<button
type="button"
className="text-sm text-blue-600 inline-flex items-center gap-1 hover:underline"
aria-label="Ver informações adicionais do paciente"
>
Informações adicionais
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
{/* barra Documentos e anexos */}
<div className="flex items-center justify-between border rounded-md px-3 py-2">
<span className="text-sm">Documentos e anexos</span>
<div className="flex gap-2">
<Button
size="icon"
variant="outline"
className="h-8 w-8"
aria-label="Enviar documento"
>
<Upload className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
className="h-8 w-8"
aria-label="Baixar documento"
>
<FileDown className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
className="h-8 w-8"
aria-label="Gerenciar etiquetas"
>
<Tag className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* ==== INFORMAÇÕES DO ATENDIMENTO ==== */}
<div className="border rounded-md p-6 space-y-4 bg-white">
<h2 className="font-medium">Informações do atendimento</h2>
{/* GRID PRINCIPAL: 12 colunas */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* COLUNA ESQUERDA (span 6) */}
<div className="md:col-span-6 space-y-3">
{/* Nome do profissional */}
<div>
<Label className="text-[13px]">
Nome do profissional <span className="text-red-600">*</span>
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
defaultValue="Robson Alves dos Anjos Neto"
className="h-10 w-full rounded-full border border-input pl-8 pr-12 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 min-w-[28px] items-center justify-center rounded-full bg-muted px-2 text-xs font-medium">
RA
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Unidade */}
<div>
<Label className="text-[13px]">
Unidade <span className="text-red-600">*</span>
</Label>
<select
defaultValue="nei"
className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="nei">
Núcleo de Especialidades Integradas
</option>
<option value="cc">Clínica Central</option>
</select>
</div>
{/* Data com ícone */}
<div>
<Label className="text-[13px]">
Data <span className="text-red-600">*</span>
</Label>
<div className="relative">
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="date"
defaultValue="2025-07-29"
className="h-10 w-full rounded-md border border-input pl-8 pr-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Início / Término / Profissional solicitante (na mesma linha) */}
<div className="grid grid-cols-12 gap-3 items-end">
{/* Início (maior) */}
<div className="col-span-12 md:col-span-3">
<Label className="text-[13px]">
Início <span className="text-red-600">*</span>
</Label>
<input
type="time"
defaultValue="21:03"
className="h-10 w-full rounded-md border border-input px-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Término (maior) */}
<div className="col-span-12 md:col-span-3">
<Label className="text-[13px]">
Término <span className="text-red-600">*</span>
</Label>
<input
type="time"
defaultValue="21:03"
className="h-10 w-full rounded-md border border-input px-3 text-[13px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Profissional solicitante */}
<div className="col-span-12 md:col-span-6">
<Label className="text-[13px]">
Profissional solicitante
</Label>
<div className="relative">
{/* ícone de busca à esquerda */}
<svg
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<select
defaultValue=""
className="h-10 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] appearance-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="" disabled>
Selecione o solicitante
</option>
<option value="1">Dr. Carlos Silva</option>
<option value="2">Dra. Maria Santos</option>
</select>
<svg
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
</div>
</div>
{/* COLUNA DIREITA — altura/posição como a imagem 1 */}
<div className="md:col-span-6 relative -top-10">
{/* toolbar */}
<div className="mb-2 flex items-center justify-end gap-1">
<Button size="icon" variant="outline" className="h-8 w-8">
<Accessibility className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8">
<Volume2 className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8">
<Flame className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" className="h-8 w-8">
<Clipboard className="h-4 w-4" />
</Button>
</div>
{/* Tipo de atendimento + campo de busca */}
<div className="mb-2">
<div className="flex items-center justify-between">
<Label className="text-[13px]">
Tipo de atendimento <span className="text-red-600">*</span>
</Label>
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-current"
/>{" "}
Pagamento via Reembolso
</label>
</div>
<div className="relative mt-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Pesquisar"
className="h-10 w-full rounded-md border border-input pl-8 pr-8 text-[13px]"
/>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
{/* Observações + imprimir */}
<div className="mb-0.2 flex items-center justify-between">
<Label className="text-[13px]">Observações</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-current"
/>{" "}
Imprimir na Etiqueta / Pulseira
</label>
</div>
{/* Textarea mais baixo e compacto */}
<Textarea
rows={4}
placeholder=""
className="text-[13px] h-[110px] min-h-0 resize-none"
/>
</div>
</div>
</div>
</main> </main>
{/* ====== FOOTER FIXO ====== */}
<FooterAgenda /> <FooterAgenda />
</div> </div>
); );

View File

@ -1,11 +1,12 @@
import type React from "react" import type React from "react"
import type { Metadata } from "next" import type { Metadata } from "next"
import { AuthProvider } from "@/hooks/useAuth"
import "./globals.css" import "./globals.css"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "SUSConecta - Conectando Pacientes e Profissionais de Saúde", title: "MediConnect - Conectando Pacientes e Profissionais de Saúde",
description: description:
"Plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.", "Plataforma inovadora que conecta pacientes, clínicas, e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS", keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
generator: 'v0.app' generator: 'v0.app'
} }
@ -17,7 +18,11 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="pt-BR" className="antialiased"> <html lang="pt-BR" className="antialiased">
<body style={{ fontFamily: "var(--font-geist-sans)" }}>{children}</body> <body style={{ fontFamily: "var(--font-geist-sans)" }}>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html> </html>
) )
} }

View File

@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AuthenticationError } from '@/lib/auth'
export default function LoginAdminPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { login } = useAuth()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// Tentar fazer login usando o contexto com tipo administrador
const success = await login(credentials.email, credentials.password, 'administrador')
if (success) {
console.log('[LOGIN-ADMIN] Login bem-sucedido, redirecionando...')
// Redirecionamento direto - solução que funcionou
window.location.href = '/dashboard'
}
} catch (err) {
console.error('[LOGIN-ADMIN] Erro no login:', err)
if (err instanceof AuthenticationError) {
setError(err.message)
} else {
setError('Erro inesperado. Tente novamente.')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Login Administrador de Clínica
</h2>
<p className="mt-2 text-sm text-gray-600">
Entre com suas credenciais para acessar o sistema administrativo
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">Acesso Administrativo</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<Input
id="email"
type="email"
placeholder="Digite seu email"
value={credentials.email}
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<Input
id="password"
type="password"
placeholder="Digite sua senha"
value={credentials.password}
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full cursor-pointer"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'}
</Button>
</form>
<div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full">
<Link href="/">
Voltar ao Início
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AuthenticationError } from '@/lib/auth'
export default function LoginPacientePage() {
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { login } = useAuth()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// Tentar fazer login usando o contexto com tipo paciente
const success = await login(credentials.email, credentials.password, 'paciente')
if (success) {
// Redirecionar para a página do paciente
router.push('/paciente')
}
} catch (err) {
console.error('[LOGIN-PACIENTE] Erro no login:', err)
if (err instanceof AuthenticationError) {
setError(err.message)
} else {
setError('Erro inesperado. Tente novamente.')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Portal do Paciente
</h2>
<p className="mt-2 text-sm text-gray-600">
Acesse sua área pessoal e gerencie suas consultas
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">Entrar como Paciente</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<Input
id="email"
type="email"
placeholder="Digite seu email"
value={credentials.email}
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<Input
id="password"
type="password"
placeholder="Digite sua senha"
value={credentials.password}
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full cursor-pointer"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar na Minha Área'}
</Button>
</form>
<div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full">
<Link href="/">
Voltar ao Início
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AuthenticationError } from '@/lib/auth'
export default function LoginPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { login } = useAuth()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// Tentar fazer login usando o contexto com tipo profissional
const success = await login(credentials.email, credentials.password, 'profissional')
if (success) {
console.log('[LOGIN-PROFISSIONAL] Login bem-sucedido, redirecionando...')
// Redirecionamento direto - solução que funcionou
window.location.href = '/profissional'
}
} catch (err) {
console.error('[LOGIN-PROFISSIONAL] Erro no login:', err)
if (err instanceof AuthenticationError) {
setError(err.message)
} else {
setError('Erro inesperado. Tente novamente.')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Login Profissional de Saúde
</h2>
<p className="mt-2 text-sm text-gray-600">
Entre com suas credenciais para acessar o sistema
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">Acesso ao Sistema</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<Input
id="email"
type="email"
placeholder="Digite seu email"
value={credentials.email}
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<Input
id="password"
type="password"
placeholder="Digite sua senha"
value={credentials.password}
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full cursor-pointer"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<div className="mt-4 text-center">
<Button variant="outline" asChild className="w-full">
<Link href="/">
Voltar ao Início
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { User, LogOut, Home } from 'lucide-react'
import Link from 'next/link'
import ProtectedRoute from '@/components/ProtectedRoute'
export default function PacientePage() {
const { logout, user } = useAuth()
const handleLogout = async () => {
console.log('[PACIENTE] Iniciando logout...')
await logout()
}
return (
<ProtectedRoute requiredUserType={["paciente"]}>
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<User className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold text-gray-900">
Portal do Paciente
</CardTitle>
<p className="text-sm text-gray-600">
Bem-vindo ao seu espaço pessoal
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Informações do Paciente */}
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-800 mb-2">
Maria Silva Santos
</h2>
<p className="text-sm text-gray-600">
CPF: 123.456.789-00
</p>
<p className="text-sm text-gray-600">
Idade: 35 anos
</p>
</div>
{/* Informações do Login */}
<div className="bg-gray-100 rounded-lg p-4">
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">
Conectado como:
</p>
<p className="font-medium text-gray-800">
{user?.email || 'paciente@example.com'}
</p>
<p className="text-xs text-gray-500 mt-1">
Tipo de usuário: Paciente
</p>
</div>
</div>
{/* Botão Voltar ao Início */}
<Button
asChild
variant="outline"
className="w-full flex items-center justify-center gap-2 cursor-pointer"
>
<Link href="/">
<Home className="h-4 w-4" />
Voltar ao Início
</Link>
</Button>
{/* Botão de Logout */}
<Button
onClick={handleLogout}
variant="destructive"
className="w-full flex items-center justify-center gap-2 cursor-pointer"
>
<LogOut className="h-4 w-4" />
Sair
</Button>
{/* Informação adicional */}
<div className="text-center">
<p className="text-xs text-gray-500">
Em breve, mais funcionalidades estarão disponíveis
</p>
</div>
</CardContent>
</Card>
</div>
</ProtectedRoute>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import type { UserType } from '@/types/auth'
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
interface ProtectedRouteProps {
children: React.ReactNode
requiredUserType?: UserType[]
}
export default function ProtectedRoute({
children,
requiredUserType
}: ProtectedRouteProps) {
const { authStatus, user } = useAuth()
const router = useRouter()
const isRedirecting = useRef(false)
useEffect(() => {
// Evitar múltiplos redirects
if (isRedirecting.current) return
// Durante loading, não fazer nada
if (authStatus === 'loading') return
// Se não autenticado, redirecionar para login
if (authStatus === 'unauthenticated') {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário NÃO autenticado - redirecionando...')
// Determinar página de login baseada no histórico
let userType: UserType = 'profissional'
if (typeof window !== 'undefined') {
try {
const storedUserType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE)
if (storedUserType && ['profissional', 'paciente', 'administrador'].includes(storedUserType)) {
userType = storedUserType as UserType
}
} catch (error) {
console.warn('[PROTECTED-ROUTE] Erro ao ler localStorage:', error)
}
}
const loginRoute = LOGIN_ROUTES[userType]
console.log('[PROTECTED-ROUTE] Redirecionando para login:', {
userType,
loginRoute,
timestamp: new Date().toLocaleTimeString()
})
router.push(loginRoute)
return
}
// Se autenticado mas não tem permissão para esta página
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
userType: user.userType,
requiredTypes: requiredUserType
})
const correctRoute = USER_TYPE_ROUTES[user.userType]
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
router.push(correctRoute)
return
}
// Se chegou aqui, acesso está autorizado
if (authStatus === 'authenticated') {
console.log('[PROTECTED-ROUTE] ACESSO AUTORIZADO!', {
userType: user?.userType,
email: user?.email,
timestamp: new Date().toLocaleTimeString()
})
isRedirecting.current = false
}
}, [authStatus, user, requiredUserType, router])
// Durante loading, mostrar spinner
if (authStatus === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Verificando autenticação...</p>
</div>
</div>
)
}
// Se não autenticado ou redirecionando, mostrar spinner
if (authStatus === 'unauthenticated' || isRedirecting.current) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Redirecionando...</p>
</div>
</div>
)
}
// Se usuário não tem permissão, mostrar fallback (não deveria chegar aqui devido ao useEffect)
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Acesso Negado</h2>
<p className="text-gray-600 mb-4">
Você não tem permissão para acessar esta página.
</p>
<p className="text-sm text-gray-500 mb-6">
Tipo de acesso necessário: {requiredUserType.join(' ou ')}
<br />
Seu tipo de acesso: {user.userType}
</p>
<button
onClick={() => router.push(USER_TYPE_ROUTES[user.userType])}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 cursor-pointer"
>
Ir para minha área
</button>
</div>
</div>
)
}
// Finalmente, renderizar conteúdo protegido
return <>{children}</>
}

View File

@ -1,20 +1,34 @@
"use client" "use client"
import { Bell, Search } from "lucide-react" import { Bell, Search, ChevronDown } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { import { useState, useEffect, useRef } from "react"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "../ui/sidebar" import { SidebarTrigger } from "../ui/sidebar"
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) { export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fechar dropdown quando clicar fora
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [dropdownOpen]);
return ( return (
<header className="h-16 border-b border-border bg-background px-6 flex items-center justify-between"> <header className="h-16 border-b border-border bg-background px-6 flex items-center justify-between">
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
@ -35,29 +49,63 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild> {/* Avatar Dropdown Simples */}
<Button variant="ghost" className="relative h-8 w-8 rounded-full"> <div className="relative" ref={dropdownRef}>
<Avatar className="h-8 w-8"> <Button
<AvatarImage src="/avatars/01.png" alt="@usuario" /> variant="ghost"
<AvatarFallback>RA</AvatarFallback> className="relative h-8 w-8 rounded-full border-2 border-gray-300 hover:border-blue-500"
</Avatar> onClick={() => setDropdownOpen(!dropdownOpen)}
</Button> >
</DropdownMenuTrigger> <Avatar className="h-8 w-8">
<DropdownMenuContent className="w-56" align="end" forceMount> <AvatarImage src="/avatars/01.png" alt="@usuario" />
<DropdownMenuLabel className="font-normal"> <AvatarFallback className="bg-blue-500 text-white font-semibold">RA</AvatarFallback>
<div className="flex flex-col space-y-1"> </Avatar>
<p className="text-sm font-medium leading-none">Dr. Roberto Alves</p> </Button>
<p className="text-xs leading-none text-muted-foreground">roberto@clinica.com</p>
{/* Dropdown Content */}
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-md shadow-lg z-50">
<div className="p-4 border-b border-gray-100">
<div className="flex flex-col space-y-1">
<p className="text-sm font-semibold leading-none">
{user?.userType === 'administrador' ? 'Administrador da Clínica' : 'Usuário do Sistema'}
</p>
{user?.email ? (
<p className="text-xs leading-none text-gray-600">{user.email}</p>
) : (
<p className="text-xs leading-none text-gray-600">Email não disponível</p>
)}
<p className="text-xs leading-none text-blue-600 font-medium">
Tipo: {user?.userType === 'administrador' ? 'Administrador' : user?.userType || 'Não definido'}
</p>
</div>
</div> </div>
</DropdownMenuLabel>
<DropdownMenuSeparator /> <div className="py-1">
<DropdownMenuItem>Perfil</DropdownMenuItem> <button className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer">
<DropdownMenuItem>Configurações</DropdownMenuItem> 👤 Perfil
<DropdownMenuSeparator /> </button>
<DropdownMenuItem>Sair</DropdownMenuItem> <button className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer">
</DropdownMenuContent> Configurações
</DropdownMenu> </button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
// Usar sempre o logout do hook useAuth (ele já redireciona corretamente)
logout();
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 cursor-pointer"
>
🚪 Sair
</button>
</div>
</div>
)}
</div>
</div> </div>
</header> </header>
) )

View File

@ -32,12 +32,11 @@ import {
const navigation = [ const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: Home }, { name: "Dashboard", href: "/dashboard", icon: Home },
{ name: "Calendario", href: "/calendar", icon: Calendar }, { name: "Calendario", href: "/calendar", icon: Calendar },
{ name: "Pacientes", href: "/dashboard/pacientes", icon: Users }, { name: "Pacientes", href: "/pacientes", icon: Users },
{ name: "Médicos", href: "/dashboard/doutores", icon: User }, { name: "Médicos", href: "/doutores", icon: User },
{ name: "Consultas", href: "/dashboard/consultas", icon: UserCheck }, { name: "Consultas", href: "/consultas", icon: UserCheck },
{ name: "Prontuários", href: "/dashboard/prontuarios", icon: FileText },
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 }, { name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
{ name: "Configurações", href: "/dashboard/configuracoes", icon: Settings }, { name: "Configurações", href: "/configuracao", icon: Settings },
] ]
export function Sidebar() { export function Sidebar() {
@ -62,7 +61,7 @@ export function Sidebar() {
{/* este span some no modo ícone */} {/* este span some no modo ícone */}
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden"> <span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
SUSConecta MediConnect
</span> </span>
</Link> </Link>
</SidebarHeader> </SidebarHeader>
@ -75,21 +74,23 @@ export function Sidebar() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{navigation.map((item) => { {navigation.map((item) => {
const isActive = pathname === item.href const isActive =
return ( pathname === item.href || pathname.startsWith(item.href + "/")
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild isActive={isActive}> return (
<Link href={item.href} className="flex items-center"> <SidebarMenuItem key={item.name}>
<item.icon className="mr-3 h-4 w-4 shrink-0" /> <SidebarMenuButton asChild isActive={isActive}>
{/* o texto esconde quando colapsa */} <Link href={item.href} className="flex items-center">
<span className="truncate group-data-[collapsible=icon]:hidden"> <item.icon className="mr-3 h-4 w-4 shrink-0" />
{item.name} <span className="truncate group-data-[collapsible=icon]:hidden">
</span> {item.name}
</Link> </span>
</SidebarMenuButton> </Link>
</SidebarMenuItem> </SidebarMenuButton>
) </SidebarMenuItem>
})} )
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>

View File

@ -13,7 +13,7 @@ export function Footer() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0"> <div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
{} {}
<div className="text-muted-foreground text-sm">© 2025 SUS Conecta</div> <div className="text-muted-foreground text-sm">© 2025 Medi Conecta</div>
{} {}
<nav className="flex items-center space-x-8"> <nav className="flex items-center space-x-8">

Binary file not shown.

View File

@ -0,0 +1,140 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Calendar, Search, ChevronDown, Upload, FileDown, Tag } from "lucide-react";
export function CalendarRegistrationForm({ initialData, onSave, onCancel }: any) {
const [formData, setFormData] = useState(initialData || {});
useEffect(() => {
setFormData(initialData || {});
}, [initialData]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev: any) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<form onSubmit={handleSubmit} className="space-y-8">
<div className="border rounded-md p-6 space-y-4 bg-white">
<h2 className="font-medium">Informações do paciente</h2>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-6">
<Label>Nome *</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
name="patientName"
placeholder="Digite o nome do paciente"
className="h-10 pl-8"
value={formData.patientName || ''}
onChange={handleChange}
/>
</div>
</div>
<div className="md:col-span-3">
<Label>CPF do paciente</Label>
<Input name="cpf" placeholder="Número do CPF" className="h-10" value={formData.cpf || ''} onChange={handleChange} />
</div>
<div className="md:col-span-3">
<Label>RG</Label>
<Input name="rg" placeholder="Número do RG" className="h-10" value={formData.rg || ''} onChange={handleChange} />
</div>
<div className="md:col-span-6">
<div className="grid grid-cols-12 gap-3">
<div className="col-span-5">
<Label>Data de nascimento *</Label>
<Input name="birthDate" type="date" className="h-10" value={formData.birthDate || ''} onChange={handleChange} />
</div>
<div className="col-span-7">
<Label>Telefone</Label>
<div className="grid grid-cols-[86px_1fr] gap-2">
<select name="phoneCode" className="h-10 rounded-md border border-input bg-background px-2 text-[13px]" value={formData.phoneCode || '+55'} onChange={handleChange}>
<option value="+55">+55</option>
<option value="+351">+351</option>
<option value="+1">+1</option>
</select>
<Input name="phoneNumber" placeholder="(99) 99999-9999" className="h-10" value={formData.phoneNumber || ''} onChange={handleChange} />
</div>
</div>
</div>
</div>
<div className="md:col-span-6">
<Label>E-mail</Label>
<Input name="email" type="email" placeholder="email@exemplo.com" className="h-10" value={formData.email || ''} onChange={handleChange} />
</div>
</div>
</div>
{}
<div className="border rounded-md p-6 space-y-4 bg-white">
<h2 className="font-medium">Informações do atendimento</h2>
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
<div className="md:col-span-6 space-y-3">
<div>
<Label className="text-[13px]">Nome do profissional *</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input name="professionalName" className="h-10 w-full rounded-full border border-input pl-8 pr-12 text-[13px]" value={formData.professionalName || ''} onChange={handleChange} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[13px]">Unidade *</Label>
<select name="unit" className="h-10 w-full rounded-md border border-input bg-background pr-8 pl-3 text-[13px] appearance-none" value={formData.unit || 'nei'} onChange={handleChange}>
<option value="nei">Núcleo de Especialidades Integradas</option>
<option value="cc">Clínica Central</option>
</select>
</div>
<div>
<Label className="text-[13px]">Data *</Label>
<div className="relative">
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input name="appointmentDate" type="date" className="h-10 w-full rounded-md border border-input pl-8 pr-3 text-[13px]" value={formData.appointmentDate || ''} onChange={handleChange} />
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-3 items-end">
<div className="col-span-12 md:col-span-3">
<Label className="text-[13px]">Início *</Label>
<Input name="startTime" type="time" className="h-10 w-full rounded-md border border-input px-3 text-[13px]" value={formData.startTime || ''} onChange={handleChange} />
</div>
<div className="col-span-12 md:col-span-3">
<Label className="text-[13px]">Término *</Label>
<Input name="endTime" type="time" className="h-10 w-full rounded-md border border-input px-3 text-[13px]" value={formData.endTime || ''} onChange={handleChange} />
</div>
</div>
</div>
<div className="md:col-span-6">
<div className="mb-2">
<Label className="text-[13px]">Tipo de atendimento *</Label>
<div className="relative mt-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name="appointmentType" placeholder="Pesquisar" className="h-10 w-full rounded-md border border-input pl-8 pr-8 text-[13px]" value={formData.appointmentType || ''} onChange={handleChange} />
</div>
</div>
<div>
<Label className="text-[13px]">Observações</Label>
<Textarea name="notes" rows={4} className="text-[13px] h-[110px] min-h-0 resize-none" value={formData.notes || ''} onChange={handleChange} />
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Cancelar</Button>
<Button type="submit">Salvar</Button>
</div>
</form>
);
}

View File

@ -24,9 +24,7 @@ import {
MedicoInput, MedicoInput,
} from "@/lib/api"; } from "@/lib/api";
import { buscarCepAPI } from "@/lib/api"; // use o seu já existente import { buscarCepAPI } from "@/lib/api";
// Mock data and types since API is not used for now
type FormacaoAcademica = { type FormacaoAcademica = {
instituicao: string; instituicao: string;
@ -179,7 +177,6 @@ export function DoctorRegistrationForm({
if (mode === "edit" && doctorId) { if (mode === "edit" && doctorId) {
const medico = await buscarMedicoPorId(doctorId); const medico = await buscarMedicoPorId(doctorId);
if (!alive) return; if (!alive) return;
// mapeia API -> estado do formulário
setForm({ setForm({
photo: null, photo: null,
nome: medico.nome ?? "", nome: medico.nome ?? "",
@ -188,7 +185,7 @@ export function DoctorRegistrationForm({
estado_crm: medico.estado_crm ?? "", estado_crm: medico.estado_crm ?? "",
rqe: medico.rqe ?? "", rqe: medico.rqe ?? "",
formacao_academica: medico.formacao_academica ?? [], formacao_academica: medico.formacao_academica ?? [],
curriculo: null, // se a API devolver URL, você pode exibir ao lado curriculo: null,
especialidade: medico.especialidade ?? "", especialidade: medico.especialidade ?? "",
cpf: medico.cpf ?? "", cpf: medico.cpf ?? "",
rg: medico.rg ?? "", rg: medico.rg ?? "",
@ -213,7 +210,7 @@ export function DoctorRegistrationForm({
valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "", valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "",
}); });
// (Opcional) listar anexos que já existem no servidor
try { try {
const list = await listarAnexosMedico(doctorId); const list = await listarAnexosMedico(doctorId);
setServerAnexos(list ?? []); setServerAnexos(list ?? []);
@ -320,7 +317,6 @@ export function DoctorRegistrationForm({
setErrors((e) => ({ ...e, submit: "" })); setErrors((e) => ({ ...e, submit: "" }));
try { try {
// monta o payload esperado pela API
const payload: MedicoInput = { const payload: MedicoInput = {
nome: form.nome, nome: form.nome,
nome_social: form.nome_social || null, nome_social: form.nome_social || null,
@ -336,7 +332,7 @@ export function DoctorRegistrationForm({
estado_crm: form.estado_crm || null, estado_crm: form.estado_crm || null,
rqe: form.rqe || null, rqe: form.rqe || null,
formacao_academica: form.formacao_academica ?? [], formacao_academica: form.formacao_academica ?? [],
curriculo_url: null, // se quiser, suba arquivo do currículo num endpoint próprio e salve a URL aqui curriculo_url: null,
especialidade: form.especialidade, especialidade: form.especialidade,
observacoes: form.observacoes || null, observacoes: form.observacoes || null,
tipo_vinculo: form.tipo_vinculo || null, tipo_vinculo: form.tipo_vinculo || null,
@ -345,14 +341,12 @@ export function DoctorRegistrationForm({
valor_consulta: form.valor_consulta || null, valor_consulta: form.valor_consulta || null,
}; };
// cria ou atualiza
const saved = mode === "create" const saved = mode === "create"
? await criarMedico(payload) ? await criarMedico(payload)
: await atualizarMedico(doctorId as number, payload); : await atualizarMedico(doctorId as number, payload);
const medicoId = saved.id; const medicoId = saved.id;
// foto (opcional)
if (form.photo) { if (form.photo) {
try { try {
await uploadFotoMedico(medicoId, form.photo); await uploadFotoMedico(medicoId, form.photo);
@ -361,7 +355,6 @@ export function DoctorRegistrationForm({
} }
} }
// anexos locais (opcional)
if (form.anexos?.length) { if (form.anexos?.length) {
for (const f of form.anexos) { for (const f of form.anexos) {
try { try {

View File

@ -17,7 +17,6 @@ import {
Paciente, Paciente,
PacienteInput, PacienteInput,
buscarCepAPI, buscarCepAPI,
validarCPF,
criarPaciente, criarPaciente,
atualizarPaciente, atualizarPaciente,
uploadFotoPaciente, uploadFotoPaciente,
@ -28,6 +27,11 @@ import {
buscarPacientePorId, buscarPacientePorId,
} from "@/lib/api"; } from "@/lib/api";
import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api";
type Mode = "create" | "edit"; type Mode = "create" | "edit";
export interface PatientRegistrationFormProps { export interface PatientRegistrationFormProps {
@ -192,13 +196,13 @@ export function PatientRegistrationForm({
telefone: form.telefone || null, telefone: form.telefone || null,
email: form.email || null, email: form.email || null,
endereco: { endereco: {
cep: form.cep || null, cep: form.cep || undefined,
logradouro: form.logradouro || null, logradouro: form.logradouro || undefined,
numero: form.numero || null, numero: form.numero || undefined,
complemento: form.complemento || null, complemento: form.complemento || undefined,
bairro: form.bairro || null, bairro: form.bairro || undefined,
cidade: form.cidade || null, cidade: form.cidade || undefined,
estado: form.estado || null, estado: form.estado || undefined,
}, },
observacoes: form.observacoes || null, observacoes: form.observacoes || null,
}; };
@ -210,18 +214,24 @@ export function PatientRegistrationForm({
try { try {
const { valido, existe } = await validarCPF(form.cpf); // 1) validação local
if (!valido) { if (!validarCPFLocal(form.cpf)) {
setErrors((e) => ({ ...e, cpf: "CPF inválido (validação externa)" })); setErrors((e) => ({ ...e, cpf: "CPF inválido" }));
return; return;
} }
if (existe && mode === "create") {
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
return;
}
} catch {
// 2) checar duplicidade no banco (apenas se criando novo paciente)
if (mode === "create") {
const existe = await verificarCpfDuplicado(form.cpf);
if (existe) {
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
return;
} }
}
} catch (err) {
console.error("Erro ao validar CPF", err);
}
setSubmitting(true); setSubmitting(true);
try { try {

View File

@ -17,7 +17,7 @@ export function Header() {
{/* Logo */} {/* Logo */}
<Link href="/" className="flex items-center space-x-2"> <Link href="/" className="flex items-center space-x-2">
<span className="text-xl font-bold text-foreground"> <span className="text-xl font-bold text-foreground">
<span className="text-primary">SUS</span>Conecta <span className="text-primary">MEDI</span>Conecta
</span> </span>
</Link> </Link>
@ -46,13 +46,14 @@ export function Header() {
<Button <Button
variant="outline" variant="outline"
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent" className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
asChild
> >
Sou Paciente <Link href="/login-paciente">Sou Paciente</Link>
</Button> </Button>
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground"> <Button className="bg-primary hover:bg-primary/90 text-primary-foreground">
<Link href="/profissional">Sou Profissional de Saúde</Link> <Link href="/login">Sou Profissional de Saúde</Link>
</Button> </Button>
<Link href="/dashboard"> <Link href="/login-admin">
<Button <Button
variant="outline" variant="outline"
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent" className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent"
@ -94,13 +95,14 @@ export function Header() {
<Button <Button
variant="outline" variant="outline"
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent" className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
asChild
> >
Sou Paciente <Link href="/login-paciente">Sou Paciente</Link>
</Button> </Button>
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full"> <Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
Sou Profissional de Saúde <Link href="/login">Sou Profissional de Saúde</Link>
</Button> </Button>
<Link href="/dashboard"> <Link href="/login-admin">
<Button <Button
variant="outline" variant="outline"
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent w-full" className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent w-full"

View File

@ -4,20 +4,20 @@ import Link from "next/link"
export function HeroSection() { export function HeroSection() {
return ( return (
<section className="py-16 lg:py-24 bg-background"> <section className="py-8 lg:py-12 bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center"> <div className="grid lg:grid-cols-2 gap-8 items-center">
{} {}
<div className="space-y-8"> <div className="space-y-6">
<div className="space-y-4"> <div className="space-y-3">
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium"> <div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
APROXIMANDO MÉDICOS E PACIENTES APROXIMANDO MÉDICOS E PACIENTES
</div> </div>
<h1 className="text-4xl lg:text-5xl font-bold text-foreground leading-tight text-balance"> <h1 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
Segurança, <span className="text-primary">Confiabilidade</span> e{" "} Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
<span className="text-primary">Rapidez</span> <span className="text-primary">Rapidez</span>
</h1> </h1>
<div className="space-y-2 text-lg text-muted-foreground"> <div className="space-y-1 text-base text-muted-foreground">
<p>Experimente o futuro dos agendamentos.</p> <p>Experimente o futuro dos agendamentos.</p>
<p>Encontre profissionais capacitados e marque sua consulta.</p> <p>Encontre profissionais capacitados e marque sua consulta.</p>
</div> </div>
@ -25,33 +25,38 @@ export function HeroSection() {
{} {}
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Button size="lg" className="bg-primary hover:bg-primary/90 text-primary-foreground"> <Button
Sou Paciente size="lg"
className="bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer"
asChild
>
<Link href="/login-paciente">Portal do Paciente</Link>
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent" className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent cursor-pointer"
asChild
> >
<Link href="/profissional">Sou Profissional de Saúde</Link> <Link href="/login">Sou Profissional de Saúde</Link>
</Button> </Button>
</div> </div>
</div> </div>
{} {}
<div className="relative"> <div className="relative">
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-8"> <div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
<img <img
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg" src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
alt="Médico profissional sorrindo" alt="Médico profissional sorrindo"
className="w-full h-auto rounded-lg" className="w-full h-auto rounded-lg min-h-80 max-h-[500px] object-cover object-center"
/> />
</div> </div>
</div> </div>
</div> </div>
{} {}
<div className="mt-16 grid md:grid-cols-3 gap-8"> <div className="mt-10 grid md:grid-cols-3 gap-6">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center"> <div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Shield className="w-4 h-4 text-primary" /> <Shield className="w-4 h-4 text-primary" />

View File

@ -0,0 +1,252 @@
'use client'
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
import { isExpired, parseJwt } from '@/lib/jwt'
import { httpClient } from '@/lib/http'
import type {
AuthContextType,
UserData,
AuthStatus,
UserType
} from '@/types/auth'
import { AUTH_STORAGE_KEYS, LOGIN_ROUTES } from '@/types/auth'
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [authStatus, setAuthStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<UserData | null>(null)
const [token, setToken] = useState<string | null>(null)
const router = useRouter()
const hasInitialized = useRef(false)
// Utilitários de armazenamento memorizados
const clearAuthData = useCallback(() => {
if (typeof window !== 'undefined') {
localStorage.removeItem(AUTH_STORAGE_KEYS.TOKEN)
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
// Manter USER_TYPE para redirecionamento correto
}
setUser(null)
setToken(null)
setAuthStatus('unauthenticated')
console.log('[AUTH] Dados de autenticação limpos - logout realizado')
}, [])
const saveAuthData = useCallback((
accessToken: string,
userData: UserData,
refreshToken?: string
) => {
try {
if (typeof window !== 'undefined') {
// Persistir dados de forma atômica
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, accessToken)
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType)
if (refreshToken) {
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
}
}
setToken(accessToken)
setUser(userData)
setAuthStatus('authenticated')
console.log('[AUTH] LOGIN realizado - Dados salvos!', {
userType: userData.userType,
email: userData.email,
timestamp: new Date().toLocaleTimeString()
})
} catch (error) {
console.error('[AUTH] Erro ao salvar dados:', error)
clearAuthData()
}
}, [clearAuthData])
// Verificação inicial de autenticação
const checkAuth = useCallback(async (): Promise<void> => {
if (typeof window === 'undefined') {
setAuthStatus('unauthenticated')
return
}
try {
const storedToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
const storedUser = localStorage.getItem(AUTH_STORAGE_KEYS.USER)
console.log('[AUTH] Verificando sessão...', {
hasToken: !!storedToken,
hasUser: !!storedUser,
timestamp: new Date().toLocaleTimeString()
})
// Pequeno delay para visualizar logs
await new Promise(resolve => setTimeout(resolve, 800))
if (!storedToken || !storedUser) {
console.log('[AUTH] Dados ausentes - sessão inválida')
await new Promise(resolve => setTimeout(resolve, 500))
clearAuthData()
return
}
// Verificar se token está expirado
if (isExpired(storedToken)) {
console.log('[AUTH] Token expirado - tentando renovar...')
await new Promise(resolve => setTimeout(resolve, 1000))
const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
if (refreshToken && !isExpired(refreshToken)) {
// Tentar renovar via HTTP client (que já tem a lógica)
try {
await httpClient.get('/auth/v1/me') // Trigger refresh se necessário
// Se chegou aqui, refresh foi bem-sucedido
const newToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
const userData = JSON.parse(storedUser) as UserData
if (newToken && newToken !== storedToken) {
setToken(newToken)
setUser(userData)
setAuthStatus('authenticated')
console.log('[AUTH] Token RENOVADO automaticamente!')
await new Promise(resolve => setTimeout(resolve, 800))
return
}
} catch (refreshError) {
console.log('❌ [AUTH] Falha no refresh automático')
await new Promise(resolve => setTimeout(resolve, 400))
}
}
clearAuthData()
return
}
// Restaurar sessão válida
const userData = JSON.parse(storedUser) as UserData
setToken(storedToken)
setUser(userData)
setAuthStatus('authenticated')
console.log('[AUTH] Sessão RESTAURADA com sucesso!', {
userId: userData.id,
userType: userData.userType,
email: userData.email,
timestamp: new Date().toLocaleTimeString()
})
await new Promise(resolve => setTimeout(resolve, 1000))
} catch (error) {
console.error('[AUTH] Erro na verificação:', error)
clearAuthData()
}
}, [clearAuthData])
// Login memoizado
const login = useCallback(async (
email: string,
password: string,
userType: UserType
): Promise<boolean> => {
try {
console.log('[AUTH] Iniciando login:', { email, userType })
const response = await loginUser(email, password, userType)
saveAuthData(
response.access_token,
response.user,
response.refresh_token
)
console.log('[AUTH] Login realizado com sucesso')
return true
} catch (error) {
console.error('[AUTH] Erro no login:', error)
if (error instanceof AuthenticationError) {
throw error
}
throw new AuthenticationError(
'Erro inesperado durante o login',
'UNKNOWN_ERROR',
error
)
}
}, [saveAuthData])
// Logout memoizado
const logout = useCallback(async (): Promise<void> => {
console.log('[AUTH] Iniciando logout')
const currentUserType = user?.userType ||
(typeof window !== 'undefined' ? localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE) : null) ||
'profissional'
try {
if (token) {
await logoutUser(token)
console.log('[AUTH] Logout realizado na API')
}
} catch (error) {
console.error('[AUTH] Erro no logout da API:', error)
}
clearAuthData()
// Redirecionamento baseado no tipo de usuário
const loginRoute = LOGIN_ROUTES[currentUserType as UserType] || '/login'
console.log('[AUTH] Redirecionando para:', loginRoute)
if (typeof window !== 'undefined') {
window.location.href = loginRoute
}
}, [user?.userType, token, clearAuthData])
// Refresh token memoizado (usado pelo HTTP client)
const refreshToken = useCallback(async (): Promise<boolean> => {
// Esta função é principalmente para compatibilidade
// O refresh real é feito pelo HTTP client
return false
}, [])
// Getters memorizados
const contextValue = useMemo(() => ({
authStatus,
user,
token,
login,
logout,
refreshToken
}), [authStatus, user, token, login, logout, refreshToken])
// Inicialização única
useEffect(() => {
if (!hasInitialized.current && typeof window !== 'undefined') {
hasInitialized.current = true
checkAuth()
}
}, [checkAuth])
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth deve ser usado dentro de AuthProvider')
}
return context
}

View File

@ -1,7 +1,7 @@
// lib/api.ts
export type ApiOk<T = any> = { export type ApiOk<T = any> = {
success: boolean; success?: boolean;
data: T; data: T;
message?: string; message?: string;
pagination?: { pagination?: {
@ -12,6 +12,7 @@ export type ApiOk<T = any> = {
}; };
}; };
// ===== TIPOS COMUNS =====
export type Endereco = { export type Endereco = {
cep?: string; cep?: string;
logradouro?: string; logradouro?: string;
@ -22,6 +23,7 @@ export type Endereco = {
estado?: string; estado?: string;
}; };
// ===== PACIENTES =====
export type Paciente = { export type Paciente = {
id: string; id: string;
nome?: string; nome?: string;
@ -46,227 +48,11 @@ export type PacienteInput = {
data_nascimento?: string | null; data_nascimento?: string | null;
telefone?: string | null; telefone?: string | null;
email?: string | null; email?: string | null;
endereco?: { endereco?: Endereco;
cep?: string | null;
logradouro?: string | null;
numero?: string | null;
complemento?: string | null;
bairro?: string | null;
cidade?: string | null;
estado?: string | null;
};
observacoes?: string | null; observacoes?: string | null;
}; };
// ===== MÉDICOS =====
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "https://mock.apidog.com/m1/1053378-0-default";
const MEDICOS_BASE = process.env.NEXT_PUBLIC_MEDICOS_BASE_PATH ?? "/medicos";
export const PATHS = {
// Pacientes (já existia)
pacientes: "/pacientes",
pacienteId: (id: string | number) => `/pacientes/${id}`,
foto: (id: string | number) => `/pacientes/${id}/foto`,
anexos: (id: string | number) => `/pacientes/${id}/anexos`,
anexoId: (id: string | number, anexoId: string | number) => `/pacientes/${id}/anexos/${anexoId}`,
validarCPF: "/pacientes/validar-cpf",
cep: (cep: string) => `/utils/cep/${cep}`,
// Médicos (APONTANDO PARA PACIENTES por enquanto)
medicos: MEDICOS_BASE,
medicoId: (id: string | number) => `${MEDICOS_BASE}/${id}`,
medicoFoto: (id: string | number) => `${MEDICOS_BASE}/${id}/foto`,
medicoAnexos: (id: string | number) => `${MEDICOS_BASE}/${id}/anexos`,
medicoAnexoId: (id: string | number, anexoId: string | number) => `${MEDICOS_BASE}/${id}/anexos/${anexoId}`,
} as const;
function headers(kind: "json" | "form" = "json"): Record<string, string> {
const h: Record<string, string> = {};
const token = process.env.NEXT_PUBLIC_API_TOKEN?.trim();
if (token) h.Authorization = `Bearer ${token}`;
if (kind === "json") h["Content-Type"] = "application/json";
return h;
}
function logAPI(title: string, info: { url?: string; payload?: any; result?: any } = {}) {
try {
console.group(`[API] ${title}`);
if (info.url) console.log("url:", info.url);
if (info.payload !== undefined) console.log("payload:", info.payload);
if (info.result !== undefined) console.log("API result:", info.result);
console.groupEnd();
} catch {}
}
async function parse<T>(res: Response): Promise<T> {
let json: any = null;
try {
json = await res.json();
} catch {
// ignora erro de parse vazio
}
if (!res.ok) {
// 🔴 ADICIONE ESSA LINHA AQUI:
console.error("[API ERROR]", res.url, res.status, json);
const code = json?.apidogError?.code ?? res.status;
const msg = json?.apidogError?.message ?? res.statusText;
throw new Error(`${code}: ${msg}`);
}
return (json?.data ?? json) as T;
}
//
// Pacientes (CRUD)
//
export async function listarPacientes(params?: { page?: number; limit?: number; q?: string }): Promise<Paciente[]> {
const query = new URLSearchParams();
if (params?.page) query.set("page", String(params.page));
if (params?.limit) query.set("limit", String(params.limit));
if (params?.q) query.set("q", params.q);
const url = `${API_BASE}${PATHS.pacientes}${query.toString() ? `?${query.toString()}` : ""}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<Paciente[]>>(res);
logAPI("listarPacientes", { url, result: data });
return data?.data ?? (data as any);
}
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("buscarPacientePorId", { url, result: data });
return data?.data ?? (data as any);
}
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacientes}`;
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("criarPaciente", { url, payload: input, result: data });
return data?.data ?? (data as any);
}
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("atualizarPaciente", { url, payload: input, result: data });
return data?.data ?? (data as any);
}
export async function excluirPaciente(id: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("excluirPaciente", { url, result: { ok: true } });
}
//
// Foto
//
export async function uploadFotoPaciente(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> {
const url = `${API_BASE}${PATHS.foto(id)}`;
const fd = new FormData();
// nome de campo mais comum no mock
fd.append("foto", file);
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res);
logAPI("uploadFotoPaciente", { url, payload: { file: file.name }, result: data });
return data?.data ?? (data as any);
}
export async function removerFotoPaciente(id: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.foto(id)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("removerFotoPaciente", { url, result: { ok: true } });
}
//
// Anexos
//
export async function listarAnexos(id: string | number): Promise<any[]> {
const url = `${API_BASE}${PATHS.anexos(id)}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<any[]>>(res);
logAPI("listarAnexos", { url, result: data });
return data?.data ?? (data as any);
}
export async function adicionarAnexo(id: string | number, file: File): Promise<any> {
const url = `${API_BASE}${PATHS.anexos(id)}`;
const fd = new FormData();
fd.append("arquivo", file);
const res = await fetch(url, { method: "POST", body: fd, headers: headers("form") });
const data = await parse<ApiOk<any>>(res);
logAPI("adicionarAnexo", { url, payload: { file: file.name }, result: data });
return data?.data ?? (data as any);
}
export async function removerAnexo(id: string | number, anexoId: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.anexoId(id, anexoId)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("removerAnexo", { url, result: { ok: true } });
}
//
// Validações
//
export async function validarCPF(cpf: string): Promise<{ valido: boolean; existe: boolean; paciente_id: string | null }> {
const url = `${API_BASE}${PATHS.validarCPF}`;
const payload = { cpf };
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(payload) });
const data = await parse<ApiOk<{ valido: boolean; existe: boolean; paciente_id: string | null }>>(res);
logAPI("validarCPF", { url, payload, result: data });
return data?.data ?? (data as any);
}
export async function buscarCepAPI(cep: string): Promise<{ logradouro?: string; bairro?: string; localidade?: string; uf?: string; erro?: boolean }> {
const clean = (cep || "").replace(/\D/g, "");
const urlMock = `${API_BASE}${PATHS.cep(clean)}`;
try {
const res = await fetch(urlMock, { method: "GET", headers: headers("json") });
const data = await parse<any>(res); // pode vir direto ou dentro de {data}
logAPI("buscarCEP (mock)", { url: urlMock, payload: { cep: clean }, result: data });
const d = data?.data ?? data ?? {};
return {
logradouro: d.logradouro ?? d.street ?? "",
bairro: d.bairro ?? d.neighborhood ?? "",
localidade: d.localidade ?? d.city ?? "",
uf: d.uf ?? d.state ?? "",
erro: false,
};
} catch {
// fallback ViaCEP
const urlVia = `https://viacep.com.br/ws/${clean}/json/`;
const resV = await fetch(urlVia);
const jsonV = await resV.json().catch(() => ({}));
logAPI("buscarCEP (ViaCEP/fallback)", { url: urlVia, payload: { cep: clean }, result: jsonV });
if (jsonV?.erro) return { erro: true };
return {
logradouro: jsonV.logradouro ?? "",
bairro: jsonV.bairro ?? "",
localidade: jsonV.localidade ?? "",
uf: jsonV.uf ?? "",
erro: false,
};
}
}
// >>> ADICIONE (ou mova) ESTES TIPOS <<<
export type FormacaoAcademica = { export type FormacaoAcademica = {
instituicao: string; instituicao: string;
curso: string; curso: string;
@ -330,85 +116,221 @@ export type MedicoInput = {
valor_consulta?: number | string | null; valor_consulta?: number | string | null;
}; };
// // ===== CONFIG =====
// MÉDICOS (CRUD) const API_BASE =
// process.env.NEXT_PUBLIC_API_BASE ?? "https://yuanqfswhberkoevtmfr.supabase.co";
// ======= MÉDICOS (forçando usar rotas de PACIENTES no mock) ======= const REST = `${API_BASE}/rest/v1`;
export async function listarMedicos(params?: { page?: number; limit?: number; q?: string }): Promise<Medico[]> { // Token salvo no browser (aceita auth_token ou token)
const query = new URLSearchParams(); function getAuthToken(): string | null {
if (params?.page) query.set("page", String(params.page)); if (typeof window === "undefined") return null;
if (params?.limit) query.set("limit", String(params.limit)); return (
if (params?.q) query.set("q", params.q); localStorage.getItem("auth_token") ||
localStorage.getItem("token") ||
sessionStorage.getItem("auth_token") ||
sessionStorage.getItem("token")
);
}
// FORÇA /pacientes // Cabeçalhos base
const url = `${API_BASE}/pacientes${query.toString() ? `?${query.toString()}` : ""}`; function baseHeaders(): Record<string, string> {
const res = await fetch(url, { method: "GET", headers: headers("json") }); const h: Record<string, string> = {
const data = await parse<ApiOk<Medico[]>>(res); apikey:
return (data as any)?.data ?? (data as any); "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ",
Accept: "application/json",
};
const jwt = getAuthToken();
if (jwt) h.Authorization = `Bearer ${jwt}`;
return h;
}
// Para POST/PATCH/DELETE e para GET com count
function withPrefer(h: Record<string, string>, prefer: string) {
return { ...h, Prefer: prefer };
}
// Parse genérico
async function parse<T>(res: Response): Promise<T> {
let json: any = null;
try {
json = await res.json();
} catch {}
if (!res.ok) {
console.error("[API ERROR]", res.url, res.status, json);
const code = (json && (json.error?.code || json.code)) ?? res.status;
const msg = (json && (json.error?.message || json.message)) ?? res.statusText;
throw new Error(`${code}: ${msg}`);
}
return (json?.data ?? json) as T;
}
// Helper de paginação (Range/Range-Unit)
function rangeHeaders(page?: number, limit?: number): Record<string, string> {
if (!page || !limit) return {};
const start = (page - 1) * limit;
const end = start + limit - 1;
return { Range: `${start}-${end}`, "Range-Unit": "items" };
}
// ===== PACIENTES (CRUD) =====
export async function listarPacientes(params?: {
page?: number;
limit?: number;
q?: string;
}): Promise<Paciente[]> {
const qs = new URLSearchParams();
if (params?.q) qs.set("q", params.q);
const url = `${REST}/patients${qs.toString() ? `?${qs.toString()}` : ""}`;
const res = await fetch(url, {
method: "GET",
headers: {
...baseHeaders(),
...rangeHeaders(params?.page, params?.limit),
},
});
return await parse<Paciente[]>(res);
}
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
const url = `${REST}/patients?id=eq.${id}`;
const res = await fetch(url, { method: "GET", headers: baseHeaders() });
const arr = await parse<Paciente[]>(res);
if (!arr?.length) throw new Error("404: Paciente não encontrado");
return arr[0];
}
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
const url = `${REST}/patients`;
const res = await fetch(url, {
method: "POST",
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
body: JSON.stringify(input),
});
const arr = await parse<Paciente[] | Paciente>(res);
return Array.isArray(arr) ? arr[0] : (arr as Paciente);
}
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
const url = `${REST}/patients?id=eq.${id}`;
const res = await fetch(url, {
method: "PATCH",
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
body: JSON.stringify(input),
});
const arr = await parse<Paciente[] | Paciente>(res);
return Array.isArray(arr) ? arr[0] : (arr as Paciente);
}
export async function excluirPaciente(id: string | number): Promise<void> {
const url = `${REST}/patients?id=eq.${id}`;
const res = await fetch(url, { method: "DELETE", headers: baseHeaders() });
await parse<any>(res);
}
// ===== PACIENTES (Extra: verificação de CPF duplicado) =====
export async function verificarCpfDuplicado(cpf: string): Promise<boolean> {
const clean = (cpf || "").replace(/\D/g, "");
const url = `${API_BASE}/rest/v1/patients?cpf=eq.${clean}&select=id`;
const res = await fetch(url, {
method: "GET",
headers: baseHeaders(),
});
const data = await res.json().catch(() => []);
return Array.isArray(data) && data.length > 0;
}
// ===== MÉDICOS (CRUD) =====
export async function listarMedicos(params?: {
page?: number;
limit?: number;
q?: string;
}): Promise<Medico[]> {
const qs = new URLSearchParams();
if (params?.q) qs.set("q", params.q);
const url = `${REST}/doctors${qs.toString() ? `?${qs.toString()}` : ""}`;
const res = await fetch(url, {
method: "GET",
headers: {
...baseHeaders(),
...rangeHeaders(params?.page, params?.limit),
},
});
return await parse<Medico[]>(res);
} }
export async function buscarMedicoPorId(id: string | number): Promise<Medico> { export async function buscarMedicoPorId(id: string | number): Promise<Medico> {
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes const url = `${REST}/doctors?id=eq.${id}`;
const res = await fetch(url, { method: "GET", headers: headers("json") }); const res = await fetch(url, { method: "GET", headers: baseHeaders() });
const data = await parse<ApiOk<Medico>>(res); const arr = await parse<Medico[]>(res);
return (data as any)?.data ?? (data as any); if (!arr?.length) throw new Error("404: Médico não encontrado");
return arr[0];
} }
export async function criarMedico(input: MedicoInput): Promise<Medico> { export async function criarMedico(input: MedicoInput): Promise<Medico> {
const url = `${API_BASE}/pacientes`; // FORÇA /pacientes const url = `${REST}/doctors`;
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) }); const res = await fetch(url, {
const data = await parse<ApiOk<Medico>>(res); method: "POST",
return (data as any)?.data ?? (data as any); headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
body: JSON.stringify(input),
});
const arr = await parse<Medico[] | Medico>(res);
return Array.isArray(arr) ? arr[0] : (arr as Medico);
} }
export async function atualizarMedico(id: string | number, input: MedicoInput): Promise<Medico> { export async function atualizarMedico(id: string | number, input: MedicoInput): Promise<Medico> {
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes const url = `${REST}/doctors?id=eq.${id}`;
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) }); const res = await fetch(url, {
const data = await parse<ApiOk<Medico>>(res); method: "PATCH",
return (data as any)?.data ?? (data as any); headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
body: JSON.stringify(input),
});
const arr = await parse<Medico[] | Medico>(res);
return Array.isArray(arr) ? arr[0] : (arr as Medico);
} }
export async function excluirMedico(id: string | number): Promise<void> { export async function excluirMedico(id: string | number): Promise<void> {
const url = `${API_BASE}/pacientes/${id}`; // FORÇA /pacientes const url = `${REST}/doctors?id=eq.${id}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") }); const res = await fetch(url, { method: "DELETE", headers: baseHeaders() });
await parse<any>(res); await parse<any>(res);
} }
export async function uploadFotoMedico(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { // ===== CEP (usado nos formulários) =====
const url = `${API_BASE}/pacientes/${id}/foto`; // FORÇA /pacientes export async function buscarCepAPI(cep: string): Promise<{
const fd = new FormData(); logradouro?: string;
fd.append("foto", file); bairro?: string;
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd }); localidade?: string;
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res); uf?: string;
return (data as any)?.data ?? (data as any); erro?: boolean;
}> {
const clean = (cep || "").replace(/\D/g, "");
try {
const res = await fetch(`https://viacep.com.br/ws/${clean}/json/`);
const json = await res.json();
if (json?.erro) return { erro: true };
return {
logradouro: json.logradouro ?? "",
bairro: json.bairro ?? "",
localidade: json.localidade ?? "",
uf: json.uf ?? "",
erro: false,
};
} catch {
return { erro: true };
}
} }
export async function removerFotoMedico(id: string | number): Promise<void> { // ===== Stubs pra não quebrar imports dos forms (sem rotas de storage na doc) =====
const url = `${API_BASE}/pacientes/${id}/foto`; // FORÇA /pacientes export async function listarAnexos(_id: string | number): Promise<any[]> { return []; }
const res = await fetch(url, { method: "DELETE", headers: headers("json") }); export async function adicionarAnexo(_id: string | number, _file: File): Promise<any> { return {}; }
await parse<any>(res); export async function removerAnexo(_id: string | number, _anexoId: string | number): Promise<void> {}
} export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; }
export async function removerFotoPaciente(_id: string | number): Promise<void> {}
export async function listarAnexosMedico(id: string | number): Promise<any[]> { export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; }
const url = `${API_BASE}/pacientes/${id}/anexos`; // FORÇA /pacientes export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; }
const res = await fetch(url, { method: "GET", headers: headers("json") }); export async function removerAnexoMedico(_id: string | number, _anexoId: string | number): Promise<void> {}
const data = await parse<ApiOk<any[]>>(res); export async function uploadFotoMedico(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> { return {}; }
return (data as any)?.data ?? (data as any); export async function removerFotoMedico(_id: string | number): Promise<void> {}
}
export async function adicionarAnexoMedico(id: string | number, file: File): Promise<any> {
const url = `${API_BASE}/pacientes/${id}/anexos`; // FORÇA /pacientes
const fd = new FormData();
fd.append("arquivo", file);
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
const data = await parse<ApiOk<any>>(res);
return (data as any)?.data ?? (data as any);
}
export async function removerAnexoMedico(id: string | number, anexoId: string | number): Promise<void> {
const url = `${API_BASE}/pacientes/${id}/anexos/${anexoId}`; // FORÇA /pacientes
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
}
// ======= FIM: médicos usando rotas de pacientes =======

388
susconecta/lib/auth.ts Normal file
View File

@ -0,0 +1,388 @@
import type {
LoginRequest,
LoginResponse,
RefreshTokenResponse,
AuthError,
UserData
} from '@/types/auth';
import { API_CONFIG, AUTH_ENDPOINTS, DEFAULT_HEADERS, API_KEY, buildApiUrl } from '@/lib/config';
import { debugRequest } from '@/lib/debug-utils';
import { ENV_CONFIG } from '@/lib/env-config';
/**
* Classe de erro customizada para autenticação
*/
export class AuthenticationError extends Error {
constructor(
message: string,
public code: string,
public details?: any
) {
super(message);
this.name = 'AuthenticationError';
}
}
/**
* Headers para requisições autenticadas (COM Bearer token)
*/
function getAuthHeaders(token: string): Record<string, string> {
return {
"Content-Type": "application/json",
"Accept": "application/json",
"apikey": API_KEY,
"Authorization": `Bearer ${token}`,
};
}
/**
* Headers APENAS para login (SEM Authorization Bearer)
*/
function getLoginHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
"Accept": "application/json",
"apikey": API_KEY,
};
}
/**
* Utilitário para processar resposta da API
*/
async function processResponse<T>(response: Response): Promise<T> {
console.log(`[AUTH] Response status: ${response.status} ${response.statusText}`);
let data: any = null;
try {
const text = await response.text();
if (text) {
data = JSON.parse(text);
}
} catch (error) {
console.log('[AUTH] Response sem JSON ou vazia (normal para alguns endpoints)');
}
if (!response.ok) {
const errorMessage = data?.message || data?.error || response.statusText || 'Erro na autenticação';
const errorCode = data?.code || String(response.status);
console.error('[AUTH ERROR]', {
url: response.url,
status: response.status,
data,
});
throw new AuthenticationError(errorMessage, errorCode, data);
}
console.log('[AUTH] Response data:', data);
return data as T;
}
/**
* Serviço para fazer login e obter token JWT
*/
export async function loginUser(
email: string,
password: string,
userType: 'profissional' | 'paciente' | 'administrador'
): Promise<LoginResponse> {
let url = AUTH_ENDPOINTS.LOGIN;
const payload = {
email,
password,
};
console.log('[AUTH-API] Iniciando login...', {
email,
userType,
url,
payload,
timestamp: new Date().toLocaleTimeString()
});
// Delay para visualizar na aba Network
await new Promise(resolve => setTimeout(resolve, 50));
try {
console.log('[AUTH-API] Enviando requisição de login...');
// Debug: Log request sem credenciais sensíveis
debugRequest('POST', url, getLoginHeaders(), payload);
let response = await fetch(url, {
method: 'POST',
headers: getLoginHeaders(),
body: JSON.stringify(payload),
});
// Se login falhar com 400, tentar criar usuário automaticamente
if (!response.ok && response.status === 400) {
console.log('[AUTH-API] Login falhou (400), tentando criar usuário...');
const signupUrl = `${ENV_CONFIG.SUPABASE_URL}/auth/v1/signup`;
const signupPayload = {
email,
password,
data: {
userType: userType,
name: email.split('@')[0],
}
};
debugRequest('POST', signupUrl, getLoginHeaders(), signupPayload);
const signupResponse = await fetch(signupUrl, {
method: 'POST',
headers: getLoginHeaders(),
body: JSON.stringify(signupPayload),
});
if (signupResponse.ok) {
console.log('[AUTH-API] Usuário criado, tentando login novamente...');
await new Promise(resolve => setTimeout(resolve, 100));
response = await fetch(url, {
method: 'POST',
headers: getLoginHeaders(),
body: JSON.stringify(payload),
});
}
}
console.log(`[AUTH-API] Login response: ${response.status} ${response.statusText}`, {
url: response.url,
status: response.status,
timestamp: new Date().toLocaleTimeString()
});
// Se ainda for 400, mostrar detalhes do erro
if (!response.ok) {
try {
const errorText = await response.text();
console.error('[AUTH-API] Erro detalhado:', {
status: response.status,
statusText: response.statusText,
body: errorText,
headers: Object.fromEntries(response.headers.entries())
});
} catch (e) {
console.error('[AUTH-API] Não foi possível ler erro da resposta');
}
}
// Delay adicional para ver status code
await new Promise(resolve => setTimeout(resolve, 50));
const data = await processResponse<any>(response);
console.log('[AUTH] Dados recebidos da API:', data);
// Verificar se recebemos os dados necessários
if (!data || (!data.access_token && !data.token)) {
console.error('[AUTH] API não retornou token válido:', data);
throw new AuthenticationError(
'API não retornou token de acesso',
'NO_TOKEN_RECEIVED',
data
);
}
// Adaptar resposta da sua API para o formato esperado
const adaptedResponse: LoginResponse = {
access_token: data.access_token || data.token,
token_type: data.token_type || "Bearer",
expires_in: data.expires_in || 3600,
user: {
id: data.user?.id || data.id || "1",
email: email,
name: data.user?.name || data.name || email.split('@')[0],
userType: userType,
profile: data.user?.profile || data.profile || {}
}
};
console.log('[AUTH-API] LOGIN REALIZADO COM SUCESSO!', {
token: adaptedResponse.access_token?.substring(0, 20) + '...',
user: {
email: adaptedResponse.user.email,
userType: adaptedResponse.user.userType
},
timestamp: new Date().toLocaleTimeString()
});
// Delay final para visualizar sucesso
await new Promise(resolve => setTimeout(resolve, 50));
return adaptedResponse;
} catch (error) {
console.error('[AUTH] Erro no login:', error);
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
'Email ou senha incorretos',
'INVALID_CREDENTIALS',
error
);
}
}
/**
* Serviço para fazer logout do usuário
*/
export async function logoutUser(token: string): Promise<void> {
const url = AUTH_ENDPOINTS.LOGOUT;
console.log('[AUTH-API] Fazendo logout na API...', {
url,
hasToken: !!token,
timestamp: new Date().toLocaleTimeString()
});
// Delay para visualizar na aba Network
await new Promise(resolve => setTimeout(resolve, 400));
try {
console.log('[AUTH-API] Enviando requisição de logout...');
const response = await fetch(url, {
method: 'POST',
headers: getAuthHeaders(token),
});
console.log(`[AUTH-API] Logout response: ${response.status} ${response.statusText}`, {
timestamp: new Date().toLocaleTimeString()
});
// Delay para ver status code
await new Promise(resolve => setTimeout(resolve, 600));
// Logout pode retornar 200, 204 ou até 401 (se token já expirou)
// Todos são considerados "sucesso" para logout
if (response.ok || response.status === 401) {
console.log('[AUTH] Logout realizado com sucesso na API');
return;
}
// Se chegou aqui, algo deu errado mas não é crítico para logout
console.warn('[AUTH] API retornou status inesperado:', response.status);
} catch (error) {
console.error('[AUTH] Erro ao chamar API de logout:', error);
}
// Para logout, sempre continuamos mesmo com erro na API
// Isso evita que o usuário fique "preso" se a API estiver indisponível
console.log('[AUTH] Logout concluído (local sempre executado)');
}
/**
* Serviço para renovar token JWT
*/
export async function refreshAuthToken(refreshToken: string): Promise<RefreshTokenResponse> {
const url = AUTH_ENDPOINTS.REFRESH;
console.log('[AUTH] Renovando token');
try {
const response = await fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"apikey": API_KEY,
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
const data = await processResponse<RefreshTokenResponse>(response);
console.log('[AUTH] Token renovado com sucesso');
return data;
} catch (error) {
console.error('[AUTH] Erro ao renovar token:', error);
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
'Não foi possível renovar a sessão',
'REFRESH_ERROR',
error
);
}
}
/**
* Serviço para obter dados do usuário atual
*/
export async function getCurrentUser(token: string): Promise<UserData> {
const url = AUTH_ENDPOINTS.USER;
console.log('[AUTH] Obtendo dados do usuário atual');
try {
const response = await fetch(url, {
method: 'GET',
headers: getAuthHeaders(token),
});
const data = await processResponse<UserData>(response);
console.log('[AUTH] Dados do usuário obtidos:', { id: data.id, email: data.email });
return data;
} catch (error) {
console.error('[AUTH] Erro ao obter usuário atual:', error);
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
'Não foi possível obter dados do usuário',
'USER_DATA_ERROR',
error
);
}
}
/**
* Utilitário para validar se um token está expirado
*/
export function isTokenExpired(expiryTimestamp: number): boolean {
const now = Date.now();
const expiry = expiryTimestamp * 1000; // Converter para milliseconds
const buffer = 5 * 60 * 1000; // Buffer de 5 minutos
return now >= (expiry - buffer);
}
/**
* Utilitário para interceptar requests e adicionar token automaticamente
*/
export function createAuthenticatedFetch(getToken: () => string | null) {
return async (url: string, options: RequestInit = {}): Promise<Response> => {
const token = getToken();
if (token) {
const headers = {
...options.headers,
...getAuthHeaders(token),
};
options = {
...options,
headers,
};
}
return fetch(url, options);
};
}

22
susconecta/lib/config.ts Normal file
View File

@ -0,0 +1,22 @@
import { ENV_CONFIG } from './env-config';
export const API_CONFIG = {
BASE_URL: ENV_CONFIG.SUPABASE_URL + "/rest/v1",
TIMEOUT: 30000,
VERSION: "v1",
} as const;
export const AUTH_ENDPOINTS = ENV_CONFIG.AUTH_ENDPOINTS;
export const API_KEY = ENV_CONFIG.SUPABASE_ANON_KEY;
export const DEFAULT_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json",
} as const;
export function buildApiUrl(endpoint: string): string {
const baseUrl = API_CONFIG.BASE_URL.replace(/\/$/, '');
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${baseUrl}${cleanEndpoint}`;
}

View File

@ -0,0 +1,34 @@
/**
* Utilitário de debug para requisições HTTP (apenas em desenvolvimento)
*/
export function debugRequest(
method: string,
url: string,
headers: Record<string, string>,
body?: any
) {
if (process.env.NODE_ENV !== 'development') return;
const headersWithoutSensitive = Object.keys(headers).reduce((acc, key) => {
// Não logar valores sensíveis, apenas nomes
if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('authorization')) {
acc[key] = '[REDACTED]';
} else {
acc[key] = headers[key];
}
return acc;
}, {} as Record<string, string>);
const bodyShape = body ? Object.keys(typeof body === 'string' ? JSON.parse(body) : body) : [];
console.log('[DEBUG] Request Preview:', {
method,
path: new URL(url).pathname,
query: new URL(url).search,
headerNames: Object.keys(headers),
headers: headersWithoutSensitive,
bodyShape,
timestamp: new Date().toISOString(),
});
}

View File

@ -0,0 +1,83 @@
/**
* Configuração segura das variáveis de ambiente
* Valida se URL e API Key pertencem ao mesmo projeto Supabase
*/
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
/**
* Extrai o REF do projeto da URL da Supabase
*/
function extractProjectRef(url: string): string | null {
const match = url.match(/https:\/\/([^.]+)\.supabase\.co/);
return match ? match[1] : null;
}
/**
* Extrai o REF do projeto da API Key JWT
*/
function extractProjectRefFromKey(apiKey: string): string | null {
try {
const payload = JSON.parse(atob(apiKey.split('.')[1]));
return payload.ref || null;
} catch {
return null;
}
}
/**
* Valida se URL e API Key pertencem ao mesmo projeto
*/
function validateProjectConsistency(): boolean {
const urlRef = extractProjectRef(SUPABASE_URL);
const keyRef = extractProjectRefFromKey(SUPABASE_ANON_KEY);
if (!urlRef || !keyRef) {
console.warn('[ENV] Não foi possível extrair REF do projeto');
return false;
}
if (urlRef !== keyRef) {
console.error('[ENV] ERRO: URL e API Key são de projetos diferentes!', {
urlRef,
keyRef
});
return false;
}
console.log('[ENV] Projeto validado:', urlRef);
return true;
}
// Validar na inicialização
if (typeof window === 'undefined') {
// Server-side
validateProjectConsistency();
} else {
// Client-side
setTimeout(() => validateProjectConsistency(), 100);
}
export const ENV_CONFIG = {
SUPABASE_URL,
SUPABASE_ANON_KEY,
PROJECT_REF: extractProjectRef(SUPABASE_URL),
// URLs dos endpoints de autenticação
AUTH_ENDPOINTS: {
LOGIN: `${SUPABASE_URL}/auth/v1/token?grant_type=password`,
LOGOUT: `${SUPABASE_URL}/auth/v1/logout`,
REFRESH: `${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`,
USER: `${SUPABASE_URL}/auth/v1/user`,
},
// Headers padrão
DEFAULT_HEADERS: {
"Content-Type": "application/json",
"apikey": SUPABASE_ANON_KEY,
},
// Validação
isValid: validateProjectConsistency(),
} as const;

260
susconecta/lib/http.ts Normal file
View File

@ -0,0 +1,260 @@
/**
* Cliente HTTP com refresh automático de token e fila de requisições
* Implementa lock para evitar múltiplas chamadas de refresh simultaneamente
*/
import { AUTH_STORAGE_KEYS } from '@/types/auth'
import { isExpired } from '@/lib/jwt'
import { API_KEY } from '@/lib/config'
interface QueuedRequest {
resolve: (value: any) => void
reject: (error: any) => void
config: RequestInit & { url: string }
}
class HttpClient {
private isRefreshing = false
private requestQueue: QueuedRequest[] = []
private baseURL: string
constructor(baseURL: string) {
this.baseURL = baseURL
}
/**
* Processa fila de requisições após refresh bem-sucedido
*/
private processQueue(error: Error | null, token: string | null = null) {
console.log(`[HTTP] Processando fila de ${this.requestQueue.length} requisições`)
this.requestQueue.forEach(({ resolve, reject, config }) => {
if (error) {
reject(error)
} else {
// Reexecutar requisição com novo token
const headers = {
...config.headers,
Authorization: `Bearer ${token}`
}
resolve(this.executeRequest({ ...config, headers }))
}
})
this.requestQueue = []
console.log('[HTTP] Fila de requisições processada')
}
/**
* Executa refresh de token uma única vez usando lock
*/
private async refreshToken(): Promise<string | null> {
const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
if (!refreshToken) {
throw new Error('No refresh token available')
}
console.log('[HTTP] Iniciando refresh de token...', {
timestamp: new Date().toLocaleTimeString()
})
const response = await fetch('https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/token?grant_type=refresh_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': API_KEY // API Key sempre necessária
},
body: JSON.stringify({ refresh_token: refreshToken })
})
if (!response.ok) {
console.log('[HTTP] Refresh falhou:', response.status)
throw new Error(`Refresh failed: ${response.status}`)
}
const data = await response.json()
// Atualizar tokens de forma atômica
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, data.access_token)
if (data.refresh_token) {
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token)
}
console.log('[HTTP] Token renovado com sucesso!', {
timestamp: new Date().toLocaleTimeString()
})
return data.access_token
}
/**
* Executa requisição HTTP com tratamento de erros
*/
private async executeRequest(config: RequestInit & { url: string }): Promise<Response> {
try {
console.log(`[HTTP] Fazendo requisição: ${config.method || 'GET'} ${config.url}`)
// Delay para visualizar na aba Network
await new Promise(resolve => setTimeout(resolve, 800))
const response = await fetch(config.url, config)
console.log(`[HTTP] Resposta recebida: ${response.status} ${response.statusText}`, {
url: config.url,
status: response.status,
timestamp: new Date().toLocaleTimeString()
})
// Se for 401 e não for uma tentativa de refresh, tentar renovar token
if (response.status === 401 && !config.url.includes('/refresh')) {
console.log('[HTTP] Status 401 - Verificando possibilidade de refresh token...')
await new Promise(resolve => setTimeout(resolve, 1000))
const token = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
if (token && !isExpired(token)) {
// Token ainda é válido, erro pode ser temporário
console.log('[HTTP] Token ainda válido - erro pode ser temporário')
await new Promise(resolve => setTimeout(resolve, 600))
return response
}
// Token expirado, tentar refresh
if (this.isRefreshing) {
// Adicionar à fila se já está fazendo refresh
return new Promise((resolve, reject) => {
this.requestQueue.push({
resolve,
reject,
config
})
})
}
this.isRefreshing = true
try {
const newToken = await this.refreshToken()
this.isRefreshing = false
// Processar fila com sucesso
this.processQueue(null, newToken)
// Reexecutar requisição original
const newHeaders = {
...config.headers,
'apikey': API_KEY, // Garantir API Key
Authorization: `Bearer ${newToken}`
}
console.log('[HTTP] Reexecutando requisição com novo token...')
await new Promise(resolve => setTimeout(resolve, 800))
return await fetch(config.url, { ...config, headers: newHeaders })
} catch (refreshError) {
this.isRefreshing = false
this.processQueue(refreshError as Error)
// Logout único em caso de falha no refresh
console.error('[HTTP] Refresh FALHOU - fazendo logout automático:', refreshError)
await new Promise(resolve => setTimeout(resolve, 1000))
this.performLogout()
throw refreshError
}
}
return response
} catch (error) {
console.error('[HTTP] Erro na requisição:', error)
throw error
}
}
/**
* Logout único com limpeza de estado
*/
private performLogout() {
// Limpar dados de autenticação
localStorage.removeItem(AUTH_STORAGE_KEYS.TOKEN)
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
// Redirecionar para login
if (typeof window !== 'undefined') {
const userType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE) || 'profissional'
const loginRoutes = {
profissional: '/login',
paciente: '/login-paciente',
administrador: '/login-admin'
}
const loginRoute = loginRoutes[userType as keyof typeof loginRoutes] || '/login'
window.location.href = loginRoute
}
}
/**
* Método público para fazer requisições autenticadas
*/
async request(url: string, options: RequestInit = {}): Promise<Response> {
const token = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
console.log(`[HTTP] Preparando requisição: ${options.method || 'GET'} ${url}`, {
hasToken: !!token,
timestamp: new Date().toLocaleTimeString()
})
const config: RequestInit & { url: string } = {
url: url.startsWith('http') ? url : `${this.baseURL}${url}`,
headers: {
'Content-Type': 'application/json',
'apikey': API_KEY, // API Key da Supabase sempre presente
...(token && { Authorization: `Bearer ${token}` }), // Bearer Token quando usuário logado
...options.headers
},
...options
}
const response = await this.executeRequest(config)
console.log(`[HTTP] Requisição finalizada: ${response.status}`, {
url: config.url,
status: response.status,
statusText: response.statusText
})
return response
}
/**
* Métodos de conveniência
*/
async get(url: string, options?: RequestInit): Promise<Response> {
return this.request(url, { ...options, method: 'GET' })
}
async post(url: string, data?: any, options?: RequestInit): Promise<Response> {
return this.request(url, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined
})
}
async put(url: string, data?: any, options?: RequestInit): Promise<Response> {
return this.request(url, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
})
}
async delete(url: string, options?: RequestInit): Promise<Response> {
return this.request(url, { ...options, method: 'DELETE' })
}
}
// Instância única do cliente HTTP
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://mock.apidog.com/m1/1053378-0-default'
export const httpClient = new HttpClient(API_BASE_URL)
export default httpClient

133
susconecta/lib/jwt.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Utilitários JWT com verificação de expiração padronizada
* Clock skew tolerance de 60 segundos para compensar diferenças de tempo
*/
interface JWTPayload {
exp?: number
iat?: number
[key: string]: any
}
const CLOCK_SKEW_SECONDS = 60
/**
* Parse JWT token payload sem validação de assinatura
* @param token JWT token
* @returns Payload decodificado ou null se inválido
*/
export function parseJwt(token: string): JWTPayload | null {
try {
const base64Url = token.split('.')[1]
if (!base64Url) return null
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
return JSON.parse(jsonPayload)
} catch (error) {
console.warn('[JWT] Erro ao fazer parse do token:', error)
return null
}
}
/**
* Verifica se token está expirado com tolerância de clock skew
* @param token JWT token ou timestamp de expiração em segundos
* @returns true se expirado
*/
export function isExpired(token: string | number): boolean {
try {
let expTimestamp: number
if (typeof token === 'string') {
const payload = parseJwt(token)
if (!payload?.exp) {
console.warn('[JWT] Token sem claim exp, considerando válido')
return false
}
expTimestamp = payload.exp
} else {
expTimestamp = token
}
const nowSeconds = Math.floor(Date.now() / 1000)
const isExpiredValue = nowSeconds >= (expTimestamp + CLOCK_SKEW_SECONDS)
console.log('[JWT] Verificação de expiração:', {
nowSeconds,
expTimestamp,
clockSkew: CLOCK_SKEW_SECONDS,
isExpired: isExpiredValue,
timeUntilExpiry: expTimestamp - nowSeconds
})
return isExpiredValue
} catch (error) {
console.warn('[JWT] Erro na verificação de expiração:', error)
return true // Assumir expirado em caso de erro
}
}
/**
* Verifica se token deve ser renovado (expira em menos de 5 minutos)
* @param token JWT token ou timestamp de expiração em segundos
* @returns true se deve renovar
*/
export function shouldRefresh(token: string | number): boolean {
try {
let expTimestamp: number
if (typeof token === 'string') {
const payload = parseJwt(token)
if (!payload?.exp) return false
expTimestamp = payload.exp
} else {
expTimestamp = token
}
const nowSeconds = Math.floor(Date.now() / 1000)
const refreshThreshold = 5 * 60 // 5 minutos
const shouldRefreshValue = nowSeconds >= (expTimestamp - refreshThreshold)
console.log('[JWT] Verificação de renovação:', {
nowSeconds,
expTimestamp,
refreshThreshold,
shouldRefresh: shouldRefreshValue,
timeUntilRefresh: expTimestamp - refreshThreshold - nowSeconds
})
return shouldRefreshValue
} catch (error) {
console.warn('[JWT] Erro na verificação de renovação:', error)
return false
}
}
/**
* Extrai informações úteis do token
* @param token JWT token
* @returns Informações do token ou null
*/
export function getTokenInfo(token: string): {
payload: JWTPayload
isExpired: boolean
shouldRefresh: boolean
expiresAt: Date | null
} | null {
const payload = parseJwt(token)
if (!payload) return null
return {
payload,
isExpired: isExpired(token),
shouldRefresh: shouldRefresh(token),
expiresAt: payload.exp ? new Date(payload.exp * 1000) : null
}
}

View File

@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function validarCPFLocal(cpf: string): boolean {
if (!cpf) return false;
cpf = cpf.replace(/[^\d]+/g, "");
if (cpf.length !== 11) return false;
if (/^(\d)\1{10}$/.test(cpf)) return false;
let soma = 0, resto = 0;
for (let i = 1; i <= 9; i++) soma += parseInt(cpf.substring(i - 1, i)) * (11 - i);
resto = (soma * 10) % 11; if (resto === 10 || resto === 11) resto = 0;
if (resto !== parseInt(cpf.substring(9, 10))) return false;
soma = 0;
for (let i = 1; i <= 10; i++) soma += parseInt(cpf.substring(i - 1, i)) * (12 - i);
resto = (soma * 10) % 11; if (resto === 10 || resto === 11) resto = 0;
if (resto !== parseInt(cpf.substring(10, 11))) return false;
return true;
}

5
susconecta/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -50,6 +50,7 @@
"embla-carousel-react": "latest", "embla-carousel-react": "latest",
"geist": "^1.3.1", "geist": "^1.3.1",
"input-otp": "latest", "input-otp": "latest",
"jspdf": "^3.0.3",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "14.2.16", "next": "14.2.16",
"next-themes": "latest", "next-themes": "latest",
@ -57,7 +58,9 @@
"react-day-picker": "latest", "react-day-picker": "latest",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "latest", "react-hook-form": "latest",
"react-quill": "^2.0.0",
"react-resizable-panels": "latest", "react-resizable-panels": "latest",
"react-signature-canvas": "^1.1.0-alpha.2",
"recharts": "latest", "recharts": "latest",
"sonner": "latest", "sonner": "latest",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@ -89,6 +92,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": { "node_modules/@date-fns/tz": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@ -2161,6 +2173,12 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2168,6 +2186,22 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/quill": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
"license": "MIT",
"dependencies": {
"parchment": "^1.1.2"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.24", "version": "18.3.24",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
@ -2189,6 +2223,19 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/signature_pad": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@types/signature_pad/-/signature_pad-2.3.6.tgz",
"integrity": "sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -2265,6 +2312,16 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.4", "version": "4.25.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
@ -2328,6 +2385,26 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2356,6 +2433,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2381,6 +2467,28 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc" "react-dom": "^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/core-js": {
"version": "3.45.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -2531,6 +2639,26 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -2547,6 +2675,16 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.213", "version": "1.5.213",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz",
@ -2620,6 +2758,35 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -2657,6 +2824,20 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/immer": { "node_modules/immer": {
"version": "10.1.3", "version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
@ -2686,6 +2867,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@ -2702,6 +2889,23 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.9",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.1", "version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@ -2941,6 +3145,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -3132,6 +3342,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3182,6 +3421,69 @@
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
},
"node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/quill/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -3251,6 +3553,21 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-quill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
"license": "MIT",
"dependencies": {
"@types/quill": "^1.3.10",
"lodash": "^4.17.4",
"quill": "^1.3.7"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@ -3331,6 +3648,36 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/react-signature-canvas": {
"version": "1.1.0-alpha.2",
"resolved": "https://registry.npmjs.org/react-signature-canvas/-/react-signature-canvas-1.1.0-alpha.2.tgz",
"integrity": "sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.17.9",
"@types/signature_pad": "^2.3.0",
"signature_pad": "^2.3.2",
"trim-canvas": "^0.1.0"
},
"funding": {
"url": "https://github.com/sponsors/agilgur5"
},
"peerDependencies": {
"@types/prop-types": "^15.7.3",
"@types/react": "0.14 - 19",
"prop-types": "^15.5.8",
"react": "0.14 - 19",
"react-dom": "0.14 - 19"
},
"peerDependenciesMeta": {
"@types/prop-types": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -3395,12 +3742,29 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -3416,6 +3780,12 @@
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/signature_pad": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz",
"integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==",
"license": "MIT"
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -3435,6 +3805,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/streamsearch": { "node_modules/streamsearch": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@ -3466,6 +3846,16 @@
} }
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@ -3523,12 +3913,28 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/trim-canvas": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/trim-canvas/-/trim-canvas-0.1.2.tgz",
"integrity": "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==",
"license": "Apache-2.0"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3648,6 +4054,16 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vaul": { "node_modules/vaul": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",

View File

@ -51,6 +51,7 @@
"embla-carousel-react": "latest", "embla-carousel-react": "latest",
"geist": "^1.3.1", "geist": "^1.3.1",
"input-otp": "latest", "input-otp": "latest",
"jspdf": "^3.0.3",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "14.2.16", "next": "14.2.16",
"next-themes": "latest", "next-themes": "latest",
@ -58,7 +59,9 @@
"react-day-picker": "latest", "react-day-picker": "latest",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "latest", "react-hook-form": "latest",
"react-quill": "^2.0.0",
"react-resizable-panels": "latest", "react-resizable-panels": "latest",
"react-signature-canvas": "^1.1.0-alpha.2",
"recharts": "latest", "recharts": "latest",
"sonner": "latest", "sonner": "latest",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",

3595
susconecta/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

90
susconecta/types/auth.ts Normal file
View File

@ -0,0 +1,90 @@
/**
* Tipos estritos para autenticação sem any
*/
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
export type UserType = 'profissional' | 'paciente' | 'administrador'
export interface UserData {
id: string
email: string
name: string
userType: UserType
profile?: {
cpf?: string
crm?: string // Para profissionais
telefone?: string
foto_url?: string
}
}
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
access_token: string
refresh_token?: string
token_type: string
expires_in: number
user: UserData
}
export interface RefreshTokenResponse {
access_token: string
token_type: string
expires_in: number
}
export interface AuthError {
message: string
code: string
details?: unknown
}
export interface AuthContextType {
authStatus: AuthStatus
user: UserData | null
token: string | null
login: (email: string, password: string, userType: UserType) => Promise<boolean>
logout: () => Promise<void>
refreshToken: () => Promise<boolean>
}
export interface AuthStorageKeys {
readonly TOKEN: string
readonly REFRESH_TOKEN: string
readonly USER: string
readonly USER_TYPE: string
}
export type UserTypeRoutes = {
readonly [K in UserType]: string
}
export type LoginRoutes = {
readonly [K in UserType]: string
}
// Constantes para localStorage
export const AUTH_STORAGE_KEYS: AuthStorageKeys = {
TOKEN: 'auth_token',
REFRESH_TOKEN: 'auth_refresh_token',
USER: 'auth_user',
USER_TYPE: 'auth_user_type',
} as const
// Rotas baseadas no tipo de usuário
export const USER_TYPE_ROUTES: UserTypeRoutes = {
profissional: '/profissional',
paciente: '/paciente',
administrador: '/dashboard',
} as const
export const LOGIN_ROUTES: LoginRoutes = {
profissional: '/login',
paciente: '/login-paciente',
administrador: '/login-admin',
} as const

View File

@ -0,0 +1 @@
declare module 'react-signature-canvas';