Compare commits
11 Commits
main
...
refatoraca
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a80c72f4a | |||
| b1655373f1 | |||
| 5277f157c9 | |||
| ac6b9f9f97 | |||
| 1bbb841265 | |||
| 0174189d6d | |||
|
|
6ec2453081 | ||
|
|
7992129652 | ||
| 7b08da79fd | |||
| 8a4e2586f4 | |||
|
|
e85b8113d2 |
270
1para.ajudar.na.refatoracao/.txt
Normal file
270
1para.ajudar.na.refatoracao/.txt
Normal file
@ -0,0 +1,270 @@
|
||||
Olá! Vamos iniciar uma sessão de refatoração de componentes React/Next.js.
|
||||
Seu Papel: A partir de agora, você atuará como um Desenvolvedor Sênior realizando uma revisão de código e refatoração. Seu objetivo não é apenas corrigir os erros óbvios, mas garantir que cada componente seja robusto, legível e siga as melhores práticas.
|
||||
Contexto do Projeto:
|
||||
A aplicação foi recentemente refatorada para usar uma camada de serviço (services/) para todas as chamadas de API e uma estrutura de layout automática do Next.js App Router. As páginas (page.tsx) estão desatualizadas e precisam ser corrigidas.
|
||||
As 6 Regras de Ouro da Refatoração (Checklist Obrigatório):
|
||||
Para CADA arquivo de página que eu fornecer, você deve aplicar TODAS as seguintes regras, sem exceção:
|
||||
[UI] Limpeza do Layout Antigo:
|
||||
REMOVER qualquer import de componentes de layout antigos (ex: import ManagerLayout from '...').
|
||||
REMOVER o componente wrapper do JSX (ex: as tags <ManagerLayout>...</ManagerLayout>). A página deve retornar apenas seu próprio conteúdo.
|
||||
[API] Substituição da Chamada de API:
|
||||
LOCALIZAR a lógica de busca de dados (geralmente em useEffect).
|
||||
SUBSTITUIR a chamada fetch antiga pela função correspondente da nossa camada de serviço (ex: fetch('/rest/v1/doctors') se torna medicosApi.list()). Use a documentação da API abaixo como referência.
|
||||
[ESTADO] Gerenciamento de Estado Robusto:
|
||||
IMPLEMENTAR estados explícitos para isLoading e error.
|
||||
O estado principal de dados deve ser inicializado como um array ou objeto vazio.
|
||||
Exemplo:
|
||||
code
|
||||
TypeScript
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
[TIPAGEM] Garantir a Segurança de Tipos (Type Safety):
|
||||
IMPORTAR as interfaces de tipo (Doctor, Patient, etc.) do arquivo de serviço correspondente.
|
||||
APLICAR essa interface ao estado do useState (ex: useState<Doctor[]>([])). Chega de any!
|
||||
[UI] Feedback Visual para o Usuário:
|
||||
ADICIONAR renderização condicional no JSX para os estados de carregamento e erro.
|
||||
Se isLoading for true, exiba um componente de "Carregando..." (pode ser um simples texto ou um spinner).
|
||||
Se error existir, exiba uma mensagem de erro para o usuário.
|
||||
Se os dados estiverem vazios após o carregamento, exiba uma mensagem como "Nenhum médico encontrado".
|
||||
[LIMPEZA] Limpeza Final do Código:
|
||||
REMOVER quaisquer variáveis, estados ou imports que se tornaram inúteis após a refatoração.
|
||||
Formato da Resposta (Obrigatório):
|
||||
Para cada arquivo que eu enviar, sua resposta deve SEMPRE seguir este formato:
|
||||
[CÓDIGO REATORADO]
|
||||
Um único bloco de código contendo o arquivo page.tsx completo e corrigido, aplicando TODAS as 6 regras.
|
||||
[RESUMO DAS ALTERAÇÕES]
|
||||
Uma lista (bullet points) explicando as principais mudanças que você fez, justificando-as com base nas "Regras de Ouro". Ex:
|
||||
[API & Estado]: Substituí o fetch por medicosApi.list() e adicionei os estados isLoading e error.
|
||||
[Tipagem]: Importei a interface Doctor e a apliquei ao estado com useState<Doctor[]>([]).
|
||||
[UI]: Adicionei renderização condicional para exibir mensagens de carregamento e erro.
|
||||
[Limpeza]: Removi o import ManagerLayout e o wrapper do JSX.
|
||||
Referência Essencial: Documentação da Camada de Serviço
|
||||
(Use esta documentação para saber qual função de serviço chamar)
|
||||
code
|
||||
Code
|
||||
// services/medicosApi.ts -> Funções: list, getById, create, update, delete. Tipos: Doctor.
|
||||
// services/pacientesApi.ts -> Funções: list, getById, create, update, delete. Tipos: Patient.
|
||||
// services/agendamentosApi.ts -> Funções: list, getById, create, update, delete, searchAvailableSlots. Tipos: Appointment.
|
||||
// services/usuariosApi.ts -> Funções: listRoles, createUser, getCurrentUser, getFullData. Tipos: User, UserRole.
|
||||
// ... (e assim por diante para todos os outros arquivos de serviço)
|
||||
Estou pronto. Por favor, me envie o código do primeiro arquivo page.tsx para ser refatorado.
|
||||
|
||||
======================================================================
|
||||
DOCUMENTAÇÃO DA CAMADA DE SERVIÇO (SERVICES)
|
||||
|
||||
Este documento descreve a arquitetura e o funcionamento da camada de serviço,
|
||||
responsável por toda a comunicação com o backend (Supabase API).
|
||||
|
||||
ARQUITETURA GERAL
|
||||
|
||||
A camada de serviço é composta por 12 arquivos, organizados por módulos
|
||||
de funcionalidade da API. A arquitetura é centralizada em um arquivo
|
||||
principal api.ts que configura o Axios, enquanto os outros arquivos
|
||||
consomem essa configuração para realizar as chamadas específicas.
|
||||
|
||||
ARQUIVO PRINCIPAL: api.ts
|
||||
|
||||
Propósito: Este é o coração da camada de serviço. Ele cria e exporta
|
||||
uma instância centralizada do Axios pré-configurada para interagir com
|
||||
a API do Supabase.
|
||||
|
||||
Configurações Principais:
|
||||
|
||||
baseURL: Aponta para https://yuanqfswhberkoevtmfr.supabase.co.
|
||||
|
||||
apikey: A chave pública (anon key) do Supabase é adicionada como
|
||||
um cabeçalho padrão em TODAS as requisições.
|
||||
|
||||
Interceptor de Requisição (Request Interceptor):
|
||||
|
||||
Antes de qualquer requisição ser enviada, o interceptor busca por um
|
||||
cookie chamado supabase-token.
|
||||
|
||||
Se o token for encontrado, ele é adicionado ao cabeçalho Authorization
|
||||
como um Bearer Token.
|
||||
|
||||
Isso automatiza o processo de autenticação para todas as rotas protegidas,
|
||||
evitando a necessidade de adicionar o token manualmente em cada chamada.
|
||||
|
||||
Importante: Este arquivo NÃO contém nenhuma função de endpoint (como
|
||||
login ou listagem de médicos). Sua única responsabilidade é a configuração
|
||||
do cliente HTTP.
|
||||
|
||||
MÓDULOS DE SERVIÇO
|
||||
|
||||
Cada arquivo a seguir representa um módulo da API e exporta um objeto
|
||||
com funções assíncronas para interagir com os endpoints.
|
||||
|
||||
2.1. autenticacaoApi.ts
|
||||
|
||||
Propósito: Gerencia todas as operações de autenticação.
|
||||
|
||||
Observação: Este módulo utiliza fetch diretamente em vez da instância
|
||||
api do Axios. Isso é necessário porque as funções de login são as que
|
||||
OBTÊM o token, que o interceptor do Axios precisa para funcionar. Ele também
|
||||
gerencia a gravação e remoção do supabase-token nos cookies do navegador.
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
loginWithEmailAndPassword(email, password): Envia credenciais para POST /auth/v1/token?grant_type=password, recebe o token de acesso e o armazena nos cookies.
|
||||
|
||||
logout(): Envia uma requisição para POST /auth/v1/logout para invalidar a sessão no Supabase e remove o token dos cookies.
|
||||
|
||||
sendMagicLink(email, redirectTo): Envia um email para POST /auth/v1/otp para login sem senha.
|
||||
|
||||
renewToken(refreshToken): Usa um refresh token para obter um novo token de acesso via POST /auth/v1/token?grant_type=refresh_token.
|
||||
|
||||
2.2. atribuicoesApi.ts
|
||||
|
||||
Propósito: Gerencia as atribuições de pacientes a profissionais.
|
||||
|
||||
Tabela Alvo: patient_assignments
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
list(): Busca a lista de todas as atribuições (GET /rest/v1/patient_assignments).
|
||||
|
||||
create(data): Cria uma nova atribuição (POST /rest/v1/patient_assignments).
|
||||
|
||||
2.3. avatarsApi.ts
|
||||
|
||||
Propósito: Gerencia o upload e a remoção de avatares no Supabase Storage.
|
||||
|
||||
Observação: As URLs e o método de envio (multipart/form-data) são
|
||||
específicos para o serviço de Storage do Supabase.
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
upload(userId, file): Envia um arquivo de imagem para POST /storage/v1/object/avatars/{userId}/avatar.
|
||||
|
||||
remove(userId): Deleta o avatar de um usuário (DELETE /storage/v1/object/avatars/{userId}/avatar).
|
||||
|
||||
getPublicUrl(userId, ext): Monta e retorna a URL pública para acessar a imagem do avatar, não faz uma chamada de API.
|
||||
|
||||
2.4. medicosApi.ts
|
||||
|
||||
Propósito: Gerencia o CRUD (Create, Read, Update, Delete) completo para o recurso de médicos.
|
||||
|
||||
Tabela Alvo: doctors
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
list(): GET /rest/v1/doctors
|
||||
|
||||
getById(id): GET /rest/v1/doctors?id=eq.{id}
|
||||
|
||||
create(data): POST /rest/v1/doctors
|
||||
|
||||
update(id, data): PATCH /rest/v1/doctors?id=eq.{id}
|
||||
|
||||
delete(id): DELETE /rest/v1/doctors?id=eq.{id}
|
||||
|
||||
2.5. pacientesApi.ts
|
||||
|
||||
Propósito: Gerencia o CRUD completo para o recurso de pacientes.
|
||||
|
||||
Tabela Alvo: patients
|
||||
|
||||
Funções Exportadas: CRUD padrão (list, getById, create, update, delete).
|
||||
|
||||
2.6. perfisApi.ts
|
||||
|
||||
Propósito: Gerencia a listagem e atualização de perfis de usuários.
|
||||
|
||||
Tabela Alvo: profiles
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
list(): GET /rest/v1/profiles
|
||||
|
||||
update(userId, data): PATCH /rest/v1/profiles?id=eq.{userId}
|
||||
|
||||
2.7. relatoriosApi.ts
|
||||
|
||||
Propósito: Gerencia o CRUD completo para o recurso de relatórios.
|
||||
|
||||
Tabela Alvo: reports
|
||||
|
||||
Funções Exportadas: CRUD padrão (list, getById, create, update, delete).
|
||||
|
||||
2.8. usuariosApi.ts
|
||||
|
||||
Propósito: Agrupa endpoints relacionados a usuários que não são CRUD direto da tabela profiles.
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
listRoles(): Busca as funções (roles) dos usuários (GET /rest/v1/user_roles).
|
||||
|
||||
createUser(data): Chama uma Supabase Function para criar um novo usuário (POST /functions/v1/create-user).
|
||||
|
||||
getCurrentUser(): Obtém os dados do usuário atualmente autenticado (GET /auth/v1/user).
|
||||
|
||||
getFullData(userId): Chama uma Supabase Function para obter dados consolidados de um usuário (GET /functions/v1/user-info).
|
||||
|
||||
2.9. smsApi.ts
|
||||
|
||||
Propósito: Responsável pelo envio de mensagens SMS.
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
send(data): Chama a Supabase Function para enviar um SMS (POST /functions/v1/send-sms).
|
||||
|
||||
2.10. agendamentosApi.ts
|
||||
|
||||
Propósito: Gerencia o CRUD de agendamentos e a busca por horários.
|
||||
|
||||
Tabela Alvo: appointments
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
CRUD padrão (list, getById, create, update, delete).
|
||||
|
||||
searchAvailableSlots(data): Chama a Supabase Function para buscar horários disponíveis (POST /functions/v1/get-available-slots).
|
||||
|
||||
2.11. disponibilidadeApi.ts
|
||||
|
||||
Propósito: Gerencia o CRUD completo para a disponibilidade dos médicos.
|
||||
|
||||
Tabela Alvo: doctor_availability
|
||||
|
||||
Funções Exportadas: CRUD padrão (list, getById, create, update, delete).
|
||||
|
||||
2.12. excecoesApi.ts
|
||||
|
||||
Propósito: Gerencia as exceções (bloqueios/liberações) na agenda dos médicos.
|
||||
|
||||
Tabela Alvo: doctor_exceptions
|
||||
|
||||
Funções Exportadas:
|
||||
|
||||
list(): GET /rest/v1/doctor_exceptions
|
||||
|
||||
create(data): POST /rest/v1/doctor_exceptions
|
||||
|
||||
delete(id): DELETE /rest/v1/doctor_exceptions?id=eq.{id}
|
||||
|
||||
COMO UTILIZAR
|
||||
|
||||
Para usar qualquer uma dessas funções em um componente ou página do Next.js,
|
||||
basta importar o módulo desejado e chamar a função. O tratamento de erros
|
||||
(com try/catch) e o gerenciamento de estado (loading, data, error) devem
|
||||
ser feitos no local onde a função é chamada.
|
||||
|
||||
Exemplo:
|
||||
|
||||
code
|
||||
TypeScript
|
||||
download
|
||||
content_copy
|
||||
expand_less
|
||||
import { medicosApi } from './services/medicosApi';
|
||||
|
||||
async function fetchDoctors() {
|
||||
try {
|
||||
const doctors = await medicosApi.list();
|
||||
console.log(doctors);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar médicos:", error);
|
||||
}
|
||||
}
|
||||
65
1para.ajudar.na.refatoracao/Default module.html
Normal file
65
1para.ajudar.na.refatoracao/Default module.html
Normal file
File diff suppressed because one or more lines are too long
@ -1,99 +1,82 @@
|
||||
// Caminho: app/context/AppointmentsContext.tsx (Completo e Corrigido)
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { agendamentosApi, Appointment, CreateAppointmentData } from '@/services/agendamentosApi';
|
||||
import { usuariosApi, User } from '@/services/usuariosApi';
|
||||
import { toast } from "sonner";
|
||||
|
||||
// A interface Appointment permanece a mesma
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
doctorName: string;
|
||||
specialty: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
phone: string;
|
||||
status: 'Agendada' | 'Realizada' | 'Cancelada';
|
||||
observations?: string;
|
||||
}
|
||||
// As definições de componentes de UI foram REMOVIDAS deste arquivo.
|
||||
// Elas pertencem aos arquivos que as utilizam, como `dashboard/page.tsx`.
|
||||
|
||||
export interface AppointmentsContextType {
|
||||
appointments: Appointment[];
|
||||
addAppointment: (appointmentData: Omit<Appointment, 'id' | 'status'>) => void;
|
||||
updateAppointment: (appointmentId: string, updatedData: Partial<Omit<Appointment, 'id'>>) => void;
|
||||
// [NOVA FUNÇÃO] Adicionando a assinatura da função de exclusão ao nosso contrato
|
||||
deleteAppointment: (appointmentId: string) => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchAppointments: () => Promise<void>;
|
||||
addAppointment: (appointmentData: CreateAppointmentData) => Promise<void>;
|
||||
updateAppointment: (appointmentId: string, updatedData: Partial<Omit<Appointment, 'id'>>) => Promise<void>;
|
||||
}
|
||||
|
||||
const AppointmentsContext = createContext<AppointmentsContextType | undefined>(undefined);
|
||||
|
||||
// Os dados iniciais permanecem os mesmos
|
||||
const initialAppointments: Appointment[] = [
|
||||
{
|
||||
id: '1',
|
||||
doctorName: "Dr. João Silva",
|
||||
specialty: "Cardiologia",
|
||||
date: "2024-08-15",
|
||||
time: "14:30",
|
||||
status: "Agendada",
|
||||
location: "Consultório A - 2º andar",
|
||||
phone: "(11) 3333-4444",
|
||||
observations: "Paciente relata dor no peito.",
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
doctorName: "Dra. Maria Santos",
|
||||
specialty: "Dermatologia",
|
||||
date: "2024-09-10",
|
||||
time: "10:00",
|
||||
status: "Agendada",
|
||||
location: "Consultório B - 1º andar",
|
||||
phone: "(11) 3333-5555",
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
doctorName: "Dr. Pedro Costa",
|
||||
specialty: "Ortopedia",
|
||||
date: "2024-07-08",
|
||||
time: "16:00",
|
||||
status: "Realizada",
|
||||
location: "Consultório C - 3º andar",
|
||||
phone: "(11) 3333-6666",
|
||||
},
|
||||
];
|
||||
|
||||
export function AppointmentsProvider({ children }: { children: ReactNode }) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>(initialAppointments);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addAppointment = (appointmentData: Omit<Appointment, 'id' | 'status'>) => {
|
||||
const newAppointment: Appointment = {
|
||||
id: Date.now().toString(),
|
||||
status: 'Agendada',
|
||||
...appointmentData,
|
||||
};
|
||||
setAppointments((prev) => [...prev, newAppointment]);
|
||||
const fetchAppointments = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await usuariosApi.getCurrentUser();
|
||||
if (user?.id) {
|
||||
const data = await agendamentosApi.listByPatient(user.id);
|
||||
setAppointments(data || []);
|
||||
} else {
|
||||
setAppointments([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erro ao buscar agendamentos:", err);
|
||||
setError("Não foi possível carregar os agendamentos.");
|
||||
setAppointments([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAppointments();
|
||||
}, [fetchAppointments]);
|
||||
|
||||
const addAppointment = async (appointmentData: CreateAppointmentData) => {
|
||||
try {
|
||||
await agendamentosApi.create(appointmentData);
|
||||
await fetchAppointments();
|
||||
} catch (err) {
|
||||
console.error("Erro ao adicionar agendamento:", err);
|
||||
setError("Falha ao criar o novo agendamento. Tente novamente.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAppointment = (appointmentId: string, updatedData: Partial<Omit<Appointment, 'id'>>) => {
|
||||
setAppointments((prev) =>
|
||||
prev.map((apt) =>
|
||||
apt.id === appointmentId ? { ...apt, ...updatedData } : apt
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// [NOVA FUNÇÃO] Implementando a lógica de exclusão real
|
||||
const deleteAppointment = (appointmentId: string) => {
|
||||
setAppointments((prev) =>
|
||||
// O método 'filter' cria um novo array com todos os itens
|
||||
// EXCETO aquele cujo ID corresponde ao que queremos excluir.
|
||||
prev.filter((apt) => apt.id !== appointmentId)
|
||||
);
|
||||
const updateAppointment = async (appointmentId: string, updatedData: Partial<Omit<Appointment, 'id'>>) => {
|
||||
try {
|
||||
toast.warning("Funcionalidade indisponível.", { description: "A API não suporta a atualização de agendamentos." });
|
||||
} catch (err) {
|
||||
console.error("Erro ao tentar atualizar agendamento:", err);
|
||||
setError("Falha ao atualizar o agendamento. Tente novamente.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
appointments,
|
||||
isLoading,
|
||||
error,
|
||||
fetchAppointments,
|
||||
addAppointment,
|
||||
updateAppointment,
|
||||
deleteAppointment, // Disponibilizando a nova função para os componentes
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
206
app/dev/api-check/page.tsx
Normal file
206
app/dev/api-check/page.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
// app/dev/api-check/page.tsx (V2 - Com Mocks e Seções Colapsáveis)
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
authService,
|
||||
userService,
|
||||
patientService,
|
||||
doctorService,
|
||||
scheduleService,
|
||||
reportService,
|
||||
} from '@/services/api/apiService';
|
||||
import * as TestData from '@/services/api/apiTestData';
|
||||
import {
|
||||
LoginResponse, SendMagicLinkResponse, LogoutResponse, GetCurrentUserResponse,
|
||||
RequestPasswordResetResponse, HardDeleteUserResponse, RegisterPatientResponse,
|
||||
ListResponse, Patient, GetAvailableSlotsResponse
|
||||
} from '@/services/api/types';
|
||||
|
||||
type ApiResponse = { status: number | 'network_error'; data?: any; error?: Error };
|
||||
|
||||
const getStyleForResponse = (response: ApiResponse | null): React.CSSProperties => {
|
||||
if (!response) return {};
|
||||
const baseStyle: React.CSSProperties = { padding: '10px', border: '1px solid', borderRadius: '4px', whiteSpace: 'pre-wrap', wordBreak: 'break-all', marginTop: '10px' };
|
||||
if (response.status === 'network_error') return { ...baseStyle, backgroundColor: 'lightgoldenrodyellow', borderColor: 'goldenrod' };
|
||||
if (response.status >= 200 && response.status < 300) return { ...baseStyle, backgroundColor: 'lightgreen', borderColor: 'green' };
|
||||
if (response.status >= 400) return { ...baseStyle, backgroundColor: 'lightcoral', borderColor: 'darkred' };
|
||||
return {};
|
||||
};
|
||||
|
||||
const ApiVerificationPage: React.FC = () => {
|
||||
// --- ESTADOS ---
|
||||
const [loginData, setLoginData] = useState(TestData.loginTestData.success);
|
||||
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState(TestData.magicLinkTestData.success);
|
||||
const [magicLinkResponse, setMagicLinkResponse] = useState<SendMagicLinkResponse | null>(null);
|
||||
const [logoutResponse, setLogoutResponse] = useState<LogoutResponse | null>(null);
|
||||
const [currentUserResponse, setCurrentUserResponse] = useState<GetCurrentUserResponse | null>(null);
|
||||
const [resetPassData, setResetPassData] = useState(TestData.resetPassTestData.success);
|
||||
const [resetPassResponse, setResetPassResponse] = useState<RequestPasswordResetResponse | null>(null);
|
||||
const [deleteUserData, setDeleteUserData] = useState(TestData.deleteUserTestData.success);
|
||||
const [deleteUserResponse, setDeleteUserResponse] = useState<HardDeleteUserResponse | null>(null);
|
||||
const [registerPatientData, setRegisterPatientData] = useState(TestData.registerPatientTestData.success);
|
||||
const [registerPatientResponse, setRegisterPatientResponse] = useState<RegisterPatientResponse | null>(null);
|
||||
const [listPatientsFilter, setListPatientsFilter] = useState(TestData.listPatientsTestData.success);
|
||||
const [listPatientsResponse, setListPatientsResponse] = useState<ListResponse<Patient> | null>(null);
|
||||
const [slotsData, setSlotsData] = useState(TestData.slotsTestData.success);
|
||||
const [slotsResponse, setSlotsResponse] = useState<GetAvailableSlotsResponse | null>(null);
|
||||
|
||||
// --- HANDLERS ---
|
||||
const handleApiCall = async (apiFunction: (...args: any[]) => Promise<any>, payload: any, setResponse: React.Dispatch<React.SetStateAction<any>>) => {
|
||||
setResponse(null);
|
||||
const response = await apiFunction(payload);
|
||||
setResponse(response);
|
||||
};
|
||||
const handleApiCallNoPayload = async (apiFunction: () => Promise<any>, setResponse: React.Dispatch<React.SetStateAction<any>>) => {
|
||||
setResponse(null);
|
||||
const response = await apiFunction();
|
||||
setResponse(response);
|
||||
};
|
||||
const handleRequestPasswordReset = async () => {
|
||||
setResetPassResponse(null);
|
||||
const response = await userService.requestPasswordReset(resetPassData.email, resetPassData.redirectUrl || undefined);
|
||||
setResetPassResponse(response);
|
||||
};
|
||||
const handleGetAvailableSlots = async () => {
|
||||
setSlotsResponse(null);
|
||||
const response = await scheduleService.getAvailableSlots(slotsData.doctorId, slotsData.date);
|
||||
setSlotsResponse(response);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', padding: '20px', maxWidth: '1000px', margin: 'auto' }}>
|
||||
<h1>Painel de Verificação da API</h1>
|
||||
<p>Use este painel para executar cada função do `apiService` e verificar o objeto de resposta completo.</p>
|
||||
|
||||
<details open>
|
||||
<summary><h2>Autenticação</h2></summary>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>authService.login</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setLoginData(TestData.loginTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setLoginData(TestData.loginTestData.error)}>Carregar Erro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(loginData, null, 2)}</pre>
|
||||
<button onClick={() => handleApiCall(authService.login, loginData, setLoginResponse)}>Executar</button>
|
||||
{loginResponse && <pre style={getStyleForResponse(loginResponse)}>{JSON.stringify(loginResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>authService.sendMagicLink</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setMagicLinkEmail(TestData.magicLinkTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setMagicLinkEmail(TestData.magicLinkTestData.error)}>Carregar Erro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(magicLinkEmail, null, 2)}</pre>
|
||||
<button onClick={() => handleApiCall(authService.sendMagicLink, magicLinkEmail.email, setMagicLinkResponse)}>Executar</button>
|
||||
{magicLinkResponse && <pre style={getStyleForResponse(magicLinkResponse)}>{JSON.stringify(magicLinkResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>authService.logout</h3></summary>
|
||||
<button onClick={() => handleApiCallNoPayload(authService.logout, setLogoutResponse)}>Executar</button>
|
||||
{logoutResponse && <pre style={getStyleForResponse(logoutResponse)}>{JSON.stringify(logoutResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>authService.getCurrentUser</h3></summary>
|
||||
<button onClick={() => handleApiCallNoPayload(authService.getCurrentUser, setCurrentUserResponse)}>Executar</button>
|
||||
{currentUserResponse && <pre style={getStyleForResponse(currentUserResponse)}>{JSON.stringify(currentUserResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h2>Usuários</h2></summary>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>userService.requestPasswordReset</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setResetPassData(TestData.resetPassTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setResetPassData(TestData.resetPassTestData.error)}>Carregar Erro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(resetPassData, null, 2)}</pre>
|
||||
<button onClick={handleRequestPasswordReset}>Executar</button>
|
||||
{resetPassResponse && <pre style={getStyleForResponse(resetPassResponse)}>{JSON.stringify(resetPassResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>userService.hardDeleteUser_DANGEROUS</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setDeleteUserData(TestData.deleteUserTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setDeleteUserData(TestData.deleteUserTestData.error)}>Carregar Erro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(deleteUserData, null, 2)}</pre>
|
||||
<button onClick={() => handleApiCall(userService.hardDeleteUser_DANGEROUS, deleteUserData.userId, setDeleteUserResponse)}>Executar</button>
|
||||
{deleteUserResponse && <pre style={getStyleForResponse(deleteUserResponse)}>{JSON.stringify(deleteUserResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h2>Pacientes</h2></summary>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>patientService.registerPatient</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setRegisterPatientData(TestData.registerPatientTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setRegisterPatientData(TestData.registerPatientTestData.errorValidation)}>Carregar Erro (Validação)</button>
|
||||
<button onClick={() => setRegisterPatientData(TestData.registerPatientTestData.errorConflict)}>Carregar Erro (Conflito)</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(registerPatientData, null, 2)}</pre>
|
||||
<button onClick={() => handleApiCall(patientService.registerPatient, registerPatientData, setRegisterPatientResponse)}>Executar</button>
|
||||
{registerPatientResponse && <pre style={getStyleForResponse(registerPatientResponse)}>{JSON.stringify(registerPatientResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>patientService.list</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setListPatientsFilter(TestData.listPatientsTestData.success)}>Carregar com Filtro</button>
|
||||
<button onClick={() => setListPatientsFilter(TestData.listPatientsTestData.noFilter)}>Carregar Sem Filtro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(listPatientsFilter, null, 2)}</pre>
|
||||
<button onClick={() => handleApiCall(patientService.list, listPatientsFilter, setListPatientsResponse)}>Executar</button>
|
||||
{listPatientsResponse && <pre style={getStyleForResponse(listPatientsResponse)}>{JSON.stringify(listPatientsResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h2>Agendamentos</h2></summary>
|
||||
<div className="test-block">
|
||||
<details>
|
||||
<summary><h3>scheduleService.getAvailableSlots</h3></summary>
|
||||
<div className="controls">
|
||||
<button onClick={() => setSlotsData(TestData.slotsTestData.success)}>Carregar Sucesso</button>
|
||||
<button onClick={() => setSlotsData(TestData.slotsTestData.error)}>Carregar Erro</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(slotsData, null, 2)}</pre>
|
||||
<button onClick={handleGetAvailableSlots}>Executar</button>
|
||||
{slotsResponse && <pre style={getStyleForResponse(slotsResponse)}>{JSON.stringify(slotsResponse, null, 2)}</pre>}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>{`
|
||||
h2 { margin-top: 20px; border-bottom: 2px solid #eee; padding-bottom: 5px; }
|
||||
summary { cursor: pointer; font-size: 1.2em; font-weight: bold; }
|
||||
details { margin-bottom: 10px; }
|
||||
.test-block { border: 1px solid #ccc; padding: 15px; margin-top: 10px; border-radius: 5px; }
|
||||
.controls button { margin-right: 10px; }
|
||||
button { margin-top: 10px; margin-bottom: 10px; padding: 8px 12px; cursor: pointer; border-radius: 4px; border: 1px solid #666; }
|
||||
pre { margin-top: 10px; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiVerificationPage;
|
||||
@ -1,47 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, User, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { AvailabilityService } from "@/services/availabilityApi.mjs";
|
||||
import { exceptionsService } from "@/services/exceptionApi.mjs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { disponibilidadeApi, Availability } from "@/services/disponibilidadeApi";
|
||||
import { excecoesApi, Exception } from "@/services/excecoesApi";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
type Availability = {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
weekday: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
slot_minutes: number;
|
||||
appointment_type: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
updated_by: string | null;
|
||||
};
|
||||
|
||||
type Schedule = {
|
||||
weekday: object;
|
||||
};
|
||||
|
||||
export default function PatientDashboard() {
|
||||
const userInfo = JSON.parse(localStorage.getItem("user_info") || "{}");
|
||||
const doctorId = "3bb9ee4a-cfdd-4d81-b628-383907dfa225"; //userInfo.id;
|
||||
const [availability, setAvailability] = useState<any | null>(null);
|
||||
const [exceptions, setExceptions] = useState<any | null>(null);
|
||||
const [availability, setAvailability] = useState<Availability[]>([]);
|
||||
const [exceptions, setExceptions] = useState<Exception[]>([]);
|
||||
const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({});
|
||||
const formatTime = (time: string) => time.split(":").slice(0, 2).join(":");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [patientToDelete, setPatientToDelete] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Mapa de tradução
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [exceptionToDelete, setExceptionToDelete] = useState<string | null>(null);
|
||||
|
||||
const formatTime = (time: string) => time.split(":").slice(0, 2).join(":");
|
||||
|
||||
const weekdaysPT: Record<string, string> = {
|
||||
sunday: "Domingo",
|
||||
monday: "Segunda",
|
||||
@ -54,76 +49,73 @@ export default function PatientDashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const doctorId = JSON.parse(localStorage.getItem("user_info") || "{}")?.id;;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// fetch para disponibilidade
|
||||
const response = await AvailabilityService.list();
|
||||
const filteredResponse = response.filter((disp: { doctor_id: any }) => disp.doctor_id == doctorId);
|
||||
setAvailability(filteredResponse);
|
||||
// fetch para exceções
|
||||
const res = await exceptionsService.list();
|
||||
const filteredRes = res.filter((disp: { doctor_id: any }) => disp.doctor_id == doctorId);
|
||||
setExceptions(filteredRes);
|
||||
const [availabilityResponse, exceptionsResponse] = await Promise.all([
|
||||
disponibilidadeApi.list(),
|
||||
excecoesApi.list(),
|
||||
]);
|
||||
|
||||
const filteredAvailability = availabilityResponse.filter(
|
||||
(disp) => disp.doctor_id === doctorId
|
||||
);
|
||||
setAvailability(filteredAvailability);
|
||||
|
||||
const filteredExceptions = exceptionsResponse.filter(
|
||||
(exc) => exc.doctor_id === doctorId
|
||||
);
|
||||
setExceptions(filteredExceptions);
|
||||
} catch (e: any) {
|
||||
alert(`${e?.error} ${e?.message}`);
|
||||
setError("Não foi possível carregar os dados do dashboard.");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const openDeleteDialog = (patientId: string) => {
|
||||
setPatientToDelete(patientId);
|
||||
const openDeleteDialog = (exceptionId: string) => {
|
||||
setExceptionToDelete(exceptionId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePatient = async (patientId: string) => {
|
||||
// Remove from current list (client-side deletion)
|
||||
const handleDeleteException = async (exceptionId: string) => {
|
||||
try {
|
||||
const res = await exceptionsService.delete(patientId);
|
||||
|
||||
let message = "Exceção deletada com sucesso";
|
||||
try {
|
||||
if (res) {
|
||||
throw new Error(`${res.error} ${res.message}` || "A API retornou erro");
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await excecoesApi.delete(exceptionId);
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: message,
|
||||
description: "Exceção deletada com sucesso.",
|
||||
});
|
||||
|
||||
setExceptions((prev: any[]) => prev.filter((p) => String(p.id) !== String(patientId)));
|
||||
setExceptions((prev) => prev.filter((ex) => ex.id !== exceptionId));
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: e?.message || "Não foi possível deletar a exceção",
|
||||
description: e?.message || "Não foi possível deletar a exceção.",
|
||||
});
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setExceptionToDelete(null);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setPatientToDelete(null);
|
||||
};
|
||||
|
||||
function formatAvailability(data: Availability[]) {
|
||||
// Agrupar os horários por dia da semana
|
||||
const schedule = data.reduce((acc: any, item) => {
|
||||
const { weekday, start_time, end_time } = item;
|
||||
|
||||
// Se o dia ainda não existe, cria o array
|
||||
if (!acc[weekday]) {
|
||||
acc[weekday] = [];
|
||||
}
|
||||
|
||||
// Adiciona o horário do dia
|
||||
acc[weekday].push({
|
||||
start: start_time,
|
||||
end: end_time,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { start: string; end: string }[]>);
|
||||
|
||||
const schedule = data.reduce(
|
||||
(acc: any, item) => {
|
||||
const { weekday, start_time, end_time } = item;
|
||||
if (!acc[weekday]) {
|
||||
acc[weekday] = [];
|
||||
}
|
||||
acc[weekday].push({
|
||||
start: start_time,
|
||||
end: end_time,
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { start: string; end: string }[]>
|
||||
);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@ -134,94 +126,102 @@ export default function PatientDashboard() {
|
||||
}
|
||||
}, [availability]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-600">Erro: {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">02 out</div>
|
||||
<p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">02 out</div>
|
||||
<p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">4</div>
|
||||
<p className="text-xs text-muted-foreground">4 agendadas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">4</div>
|
||||
<p className="text-xs text-muted-foreground">4 agendadas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<p className="text-xs text-muted-foreground">Dados completos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<p className="text-xs text-muted-foreground">Dados completos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/doctor/medicos/consultas">
|
||||
<Button className="w-full justify-start">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Ver Minhas Consultas
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/doctor/medicos/consultas">
|
||||
<Button className="w-full justify-start">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Ver Minhas Consultas
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximas Consultas</CardTitle>
|
||||
<CardDescription>Suas consultas agendadas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Dr. João Santos</p>
|
||||
<p className="text-sm text-gray-600">Cardiologia</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">02 out</p>
|
||||
<p className="text-sm text-gray-600">14:30</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximas Consultas</CardTitle>
|
||||
<CardDescription>Suas consultas agendadas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Dr. João Santos</p>
|
||||
<p className="text-sm text-gray-600">Cardiologia</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">02 out</p>
|
||||
<p className="text-sm text-gray-600">14:30</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Horário Semanal</CardTitle>
|
||||
<CardDescription>Confira rapidamente a sua disponibilidade da semana</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
|
||||
{["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"].map((day) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Horário Semanal</CardTitle>
|
||||
<CardDescription>Confira rapidamente a sua disponibilidade da semana</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
|
||||
{Object.keys(schedule).length > 0 ? (
|
||||
["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"].map((day) => {
|
||||
const times = schedule[day] || [];
|
||||
return (
|
||||
<div key={day} className="space-y-4">
|
||||
@ -243,73 +243,75 @@ export default function PatientDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exceções</CardTitle>
|
||||
<CardDescription>Bloqueios e liberações eventuais de agenda</CardDescription>
|
||||
</CardHeader>
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic col-span-7 text-center">Nenhum horário de disponibilidade encontrado.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exceções</CardTitle>
|
||||
<CardDescription>Bloqueios e liberações eventuais de agenda</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
|
||||
{exceptions && exceptions.length > 0 ? (
|
||||
exceptions.map((ex: any) => {
|
||||
// Formata data e hora
|
||||
const date = new Date(ex.date).toLocaleDateString("pt-BR", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
});
|
||||
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
|
||||
{exceptions.length > 0 ? (
|
||||
exceptions.map((ex) => {
|
||||
const date = new Date(ex.date).toLocaleDateString("pt-BR", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
});
|
||||
const startTime = formatTime(ex.start_time);
|
||||
const endTime = formatTime(ex.end_time);
|
||||
|
||||
const startTime = formatTime(ex.start_time);
|
||||
const endTime = formatTime(ex.end_time);
|
||||
|
||||
return (
|
||||
<div key={ex.id} className="space-y-4">
|
||||
<div className="flex flex-col items-center justify-between p-3 bg-blue-50 rounded-lg shadow-sm">
|
||||
<div className="text-center">
|
||||
<p className="font-semibold capitalize">{date}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{startTime} - {endTime} <br/> -
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<p className={`text-sm font-medium ${ex.kind === "bloqueio" ? "text-red-600" : "text-green-600"}`}>{ex.kind === "bloqueio" ? "Bloqueio" : "Liberação"}</p>
|
||||
<p className="text-xs text-gray-500 italic">{ex.reason || "Sem motivo especificado"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button className="text-red-600" variant="outline" onClick={() => openDeleteDialog(String(ex.id))}>
|
||||
<Trash2></Trash2>
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<div key={ex.id} className="space-y-4">
|
||||
<div className="flex flex-col items-center justify-between p-3 bg-blue-50 rounded-lg shadow-sm">
|
||||
<div className="text-center">
|
||||
<p className="font-semibold capitalize">{date}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{startTime} - {endTime} <br /> -
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<p className={`text-sm font-medium ${ex.kind === "bloqueio" ? "text-red-600" : "text-green-600"}`}>
|
||||
{ex.kind === "bloqueio" ? "Bloqueio" : "Liberação"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 italic">{ex.reason || "Sem motivo especificado"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button className="text-red-600" variant="outline" onClick={() => openDeleteDialog(String(ex.id))}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic col-span-7 text-center">Nenhuma exceção registrada.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>Tem certeza que deseja excluir este paciente? Esta ação não pode ser desfeita.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => patientToDelete && handleDeletePatient(patientToDelete)} className="bg-red-600 hover:bg-red-700">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 italic col-span-7 text-center">Nenhuma exceção registrada.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>Tem certeza que deseja excluir esta exceção? Esta ação não pode ser desfeita.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => exceptionToDelete && handleDeleteException(exceptionToDelete)} className="bg-red-600 hover:bg-red-700">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,216 +3,243 @@
|
||||
import type React from "react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Clock, Calendar as CalendarIcon, MapPin, Phone, User, X, RefreshCw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar as CalendarIcon, RefreshCw } from "lucide-react";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { format } from "date-fns";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { exceptionsService } from "@/services/exceptionApi.mjs";
|
||||
|
||||
// IMPORTAR O COMPONENTE CALENDÁRIO DA SHADCN
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { format } from "date-fns"; // Usaremos o date-fns para formatação e comparação de datas
|
||||
// [TIPAGEM] Interfaces importadas ou definidas localmente conforme a documentação
|
||||
import { excecoesApi } from "@/services/excecoesApi";
|
||||
import { agendamentosApi, Appointment } from "@/services/agendamentosApi";
|
||||
import { usuariosApi, User } from "@/services/usuariosApi";
|
||||
|
||||
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
|
||||
|
||||
// --- TIPAGEM DA CONSULTA SALVA NO LOCALSTORAGE ---
|
||||
interface LocalStorageAppointment {
|
||||
id: number;
|
||||
patientName: string;
|
||||
doctor: string;
|
||||
specialty: string;
|
||||
date: string; // Data no formato YYYY-MM-DD
|
||||
time: string; // Hora no formato HH:MM
|
||||
status: "agendada" | "confirmada" | "cancelada" | "realizada";
|
||||
location: string;
|
||||
phone: string;
|
||||
// Tipos definidos localmente baseados na documentação da API, já que não são exportados pelos serviços
|
||||
interface DoctorExceptionCreate {
|
||||
doctor_id: string;
|
||||
created_by: string;
|
||||
date: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
kind: 'bloqueio' | 'liberacao';
|
||||
reason?: string | FormDataEntryValue | null;
|
||||
}
|
||||
|
||||
const LOGGED_IN_DOCTOR_NAME = "Dr. João Santos";
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Função auxiliar para comparar se duas datas (Date objects) são o mesmo dia
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate();
|
||||
};
|
||||
|
||||
// --- COMPONENTE PRINCIPAL ---
|
||||
interface UserInfoResponse {
|
||||
user: User;
|
||||
profile: Profile;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export default function ExceptionPage() {
|
||||
const [allAppointments, setAllAppointments] = useState<LocalStorageAppointment[]>([]);
|
||||
const router = useRouter();
|
||||
const [filteredAppointments, setFilteredAppointments] = useState<LocalStorageAppointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const userInfo = JSON.parse(localStorage.getItem("user_info") || "{}");
|
||||
const doctorIdTemp = "3bb9ee4a-cfdd-4d81-b628-383907dfa225";
|
||||
const [tipo, setTipo] = useState<string>("");
|
||||
|
||||
// NOVO ESTADO 1: Armazena os dias com consultas (para o calendário)
|
||||
// [ESTADO] Estados robustos para dados, carregamento e erro
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [bookedDays, setBookedDays] = useState<Date[]>([]);
|
||||
|
||||
// NOVO ESTADO 2: Armazena a data selecionada no calendário
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
|
||||
const [tipo, setTipo] = useState<'bloqueio' | 'liberacao' | "">("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isLoading) return;
|
||||
//setIsLoading(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const apiPayload = {
|
||||
doctor_id: doctorIdTemp,
|
||||
created_by: doctorIdTemp,
|
||||
date: selectedCalendarDate ? format(selectedCalendarDate, "yyyy-MM-dd") : "",
|
||||
start_time: ((formData.get("horarioEntrada") + ":00") as string) || undefined,
|
||||
end_time: ((formData.get("horarioSaida") + ":00") as string) || undefined,
|
||||
kind: tipo || undefined,
|
||||
reason: formData.get("reason"),
|
||||
};
|
||||
console.log(apiPayload);
|
||||
// [API] Função centralizada e corrigida para buscar dados
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await exceptionsService.create(apiPayload);
|
||||
console.log(res);
|
||||
// A função getFullData precisa de um ID, então buscamos o usuário atual primeiro.
|
||||
const currentUser = await usuariosApi.getCurrentUser();
|
||||
if (!currentUser || !currentUser.id) {
|
||||
throw new Error("Usuário não autenticado.");
|
||||
}
|
||||
|
||||
let message = "Exceção cadastrada com sucesso";
|
||||
try {
|
||||
if (!res[0].id) {
|
||||
throw new Error(`${res.error} ${res.message}` || "A API retornou erro");
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
} catch {}
|
||||
const [userData, appointmentsData] = await Promise.all([
|
||||
usuariosApi.getFullData(currentUser.id), // Corrigido: Passando o ID do usuário
|
||||
agendamentosApi.list()
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: message,
|
||||
});
|
||||
router.push("/doctor/dashboard"); // adicionar página para listar a disponibilidade
|
||||
setUserInfo(userData as UserInfoResponse);
|
||||
setAppointments(appointmentsData);
|
||||
|
||||
const booked = appointmentsData.map(app => new Date(app.scheduled_at));
|
||||
setBookedDays(booked);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || "Erro ao carregar os dados da agenda. Tente novamente.";
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: err?.message || "Não foi possível cadastrar a exceção",
|
||||
title: "Erro de Carregamento",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting || !userInfo?.profile?.id || !userInfo?.user?.id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const apiPayload: DoctorExceptionCreate = {
|
||||
doctor_id: userInfo.profile.id,
|
||||
created_by: userInfo.user.id,
|
||||
date: selectedCalendarDate ? format(selectedCalendarDate, "yyyy-MM-dd") : "",
|
||||
start_time: (formData.get("horarioEntrada") as string) + ":00",
|
||||
end_time: (formData.get("horarioSaida") as string) + ":00",
|
||||
kind: tipo as 'bloqueio' | 'liberacao',
|
||||
reason: formData.get("reason"),
|
||||
};
|
||||
|
||||
try {
|
||||
await excecoesApi.create(apiPayload);
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: "Exceção cadastrada com sucesso.",
|
||||
});
|
||||
router.push("/doctor/dashboard");
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Erro ao Salvar",
|
||||
description: err?.message || "Não foi possível cadastrar a exceção.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayDate = selectedCalendarDate ? new Date(selectedCalendarDate).toLocaleDateString("pt-BR", { weekday: "long", day: "2-digit", month: "long" }) : "Selecione uma data";
|
||||
|
||||
// [UI] Feedback Visual para Carregamento e Erro
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-64 p-4">Carregando dados da agenda...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-md text-center">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Adicione exceções</h1>
|
||||
<p className="text-gray-600">Altere a disponibilidade em casos especiais para o Dr. {userInfo.user_metadata.full_name}</p>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Adicionar exceções</h1>
|
||||
<p className="text-gray-600">Altere a disponibilidade em casos especiais para o Dr. {userInfo?.profile?.full_name || '...'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Agenda para: {displayDate}</h2>
|
||||
<Button onClick={fetchData} disabled={isLoading} variant="outline" size="sm">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Atualizar Agenda
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<CalendarIcon className="mr-2 h-5 w-5" />
|
||||
Calendário
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-500">Selecione a data desejada.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedCalendarDate}
|
||||
onSelect={setSelectedCalendarDate}
|
||||
autoFocus
|
||||
modifiers={{ booked: bookedDays }}
|
||||
modifiersClassNames={{
|
||||
booked: "bg-blue-600 text-white aria-selected:!bg-blue-700 hover:!bg-blue-700/90",
|
||||
}}
|
||||
className="rounded-md border p-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Consultas para: {displayDate}</h2>
|
||||
<Button disabled={isLoading} variant="outline" size="sm">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Atualizar Agenda
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* NOVO LAYOUT DE DUAS COLUNAS */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* COLUNA 1: CALENDÁRIO */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<CalendarIcon className="mr-2 h-5 w-5" />
|
||||
Calendário
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-500">Selecione a data desejada.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedCalendarDate}
|
||||
onSelect={setSelectedCalendarDate}
|
||||
autoFocus
|
||||
// A CHAVE DO HIGHLIGHT: Passa o array de datas agendadas
|
||||
modifiers={{ booked: bookedDays }}
|
||||
// Define o estilo CSS para o modificador 'booked'
|
||||
modifiersClassNames={{
|
||||
booked: "bg-blue-600 text-white aria-selected:!bg-blue-700 hover:!bg-blue-700/90",
|
||||
}}
|
||||
className="rounded-md border p-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* COLUNA 2: FORM PARA ADICIONAR EXCEÇÃO */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-center text-lg text-gray-500">Carregando a agenda...</p>
|
||||
) : !selectedCalendarDate ? (
|
||||
<p className="text-center text-lg text-gray-500">Selecione uma data.</p>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados </h2>
|
||||
<div className="space-y-6">
|
||||
<div className="grid md:grid-cols-5 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="horarioEntrada" className="text-sm font-medium text-gray-700">
|
||||
Horario De Entrada
|
||||
</Label>
|
||||
<Input type="time" id="horarioEntrada" name="horarioEntrada" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="horarioSaida" className="text-sm font-medium text-gray-700">
|
||||
Horario De Saida
|
||||
</Label>
|
||||
<Input type="time" id="horarioSaida" name="horarioSaida" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{!selectedCalendarDate ? (
|
||||
<p className="text-center text-lg text-gray-500">Selecione uma data no calendário.</p>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados da Exceção</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="tipo" className="text-sm font-medium text-gray-700">
|
||||
Tipo
|
||||
<Label htmlFor="horarioEntrada" className="text-sm font-medium text-gray-700">
|
||||
Horário De Início
|
||||
</Label>
|
||||
<Select onValueChange={(value) => setTipo(value)} value={tipo}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bloqueio">Bloqueio </SelectItem>
|
||||
<SelectItem value="liberacao">Liberação</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="time" id="horarioEntrada" name="horarioEntrada" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reason" className="text-sm font-medium text-gray-700">
|
||||
Motivo
|
||||
<Label htmlFor="horarioSaida" className="text-sm font-medium text-gray-700">
|
||||
Horário De Fim
|
||||
</Label>
|
||||
<Input type="textarea" id="reason" name="reason" required className="mt-1" />
|
||||
<Input type="time" id="horarioSaida" name="horarioSaida" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/doctor/disponibilidade">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
Salvar Exceção
|
||||
</Button>
|
||||
<div>
|
||||
<Label htmlFor="tipo" className="text-sm font-medium text-gray-700">
|
||||
Tipo
|
||||
</Label>
|
||||
<Select onValueChange={(value) => setTipo(value as 'bloqueio' | 'liberacao')} value={tipo} required>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bloqueio">Bloqueio</SelectItem>
|
||||
<SelectItem value="liberacao">Liberação</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reason" className="text-sm font-medium text-gray-700">
|
||||
Motivo
|
||||
</Label>
|
||||
<Input id="reason" name="reason" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/doctor/disponibilidade">
|
||||
<Button variant="outline" type="button">Cancelar</Button>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Exceção"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,26 +6,33 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { AvailabilityService } from "@/services/availabilityApi.mjs";
|
||||
import { disponibilidadeApi, Availability } from "@/services/disponibilidadeApi";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AvailabilityPage() {
|
||||
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const userInfo = JSON.parse(localStorage.getItem("user_info") || "{}");
|
||||
const doctorIdTemp = "3bb9ee4a-cfdd-4d81-b628-383907dfa225";
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalidadeConsulta, setModalidadeConsulta] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
// TODO: Substituir pelo ID do médico autenticado
|
||||
const doctorId = JSON.parse(localStorage.getItem("user_info") || "{}")?.id;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await AvailabilityService.list();
|
||||
console.log(response);
|
||||
const response = await disponibilidadeApi.list();
|
||||
setAvailabilities(response || []);
|
||||
} catch (e: any) {
|
||||
alert(`${e?.error} ${e?.message}`);
|
||||
setError("Não foi possível carregar as disponibilidades existentes.");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -34,14 +41,14 @@ export default function AvailabilityPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const apiPayload = {
|
||||
doctor_id: doctorIdTemp,
|
||||
created_by: doctorIdTemp,
|
||||
doctor_id: doctorId,
|
||||
created_by: doctorId, // TODO: Substituir pelo ID do usuário autenticado
|
||||
weekday: (formData.get("weekday") as string) || undefined,
|
||||
start_time: (formData.get("horarioEntrada") as string) || undefined,
|
||||
end_time: (formData.get("horarioSaida") as string) || undefined,
|
||||
@ -49,138 +56,134 @@ export default function AvailabilityPage() {
|
||||
appointment_type: modalidadeConsulta || undefined,
|
||||
active: true,
|
||||
};
|
||||
console.log(apiPayload);
|
||||
|
||||
try {
|
||||
const res = await AvailabilityService.create(apiPayload);
|
||||
console.log(res);
|
||||
|
||||
let message = "disponibilidade cadastrada com sucesso";
|
||||
try {
|
||||
if (!res[0].id) {
|
||||
throw new Error(`${res.error} ${res.message}` || "A API retornou erro");
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await disponibilidadeApi.create(apiPayload as Omit<Availability, 'id'>);
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: message,
|
||||
description: "Disponibilidade cadastrada com sucesso.",
|
||||
});
|
||||
router.push("#"); // adicionar página para listar a disponibilidade
|
||||
router.push("/doctor/dashboard"); // Redirecionar para uma página de listagem ou dashboard
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Não foi possível cadastrar a disponibilidade.");
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: err?.message || "Não foi possível cadastrar o paciente",
|
||||
description: err?.message || "Não foi possível cadastrar a disponibilidade.",
|
||||
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Erro: {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Definir Disponibilidade</h1>
|
||||
<p className="text-gray-600">Defina sua disponibilidade para consultas </p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Definir Disponibilidade</h1>
|
||||
<p className="text-gray-600">Defina sua disponibilidade para consultas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">Dia Da Semana</Label>
|
||||
<div className="flex gap-4 mt-2 flex-nowrap">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="monday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Segunda-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="tuesday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Terça-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="wednesday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Quarta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="thursday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Quinta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="friday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Sexta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="saturday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Sábado</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="sunday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Domingo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="horarioEntrada" className="text-sm font-medium text-gray-700">
|
||||
Horário De Entrada
|
||||
</Label>
|
||||
<Input type="time" id="horarioEntrada" name="horarioEntrada" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="horarioSaida" className="text-sm font-medium text-gray-700">
|
||||
Horário De Saída
|
||||
</Label>
|
||||
<Input type="time" id="horarioSaida" name="horarioSaida" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="duracaoConsulta" className="text-sm font-medium text-gray-700">
|
||||
Duração Da Consulta (min)
|
||||
</Label>
|
||||
<Input type="number" id="duracaoConsulta" name="duracaoConsulta" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modalidadeConsulta" className="text-sm font-medium text-gray-700">
|
||||
Modalidade De Consulta
|
||||
</Label>
|
||||
<Select onValueChange={(value) => setModalidadeConsulta(value)} value={modalidadeConsulta}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="presencial">Presencial</SelectItem>
|
||||
<SelectItem value="telemedicina">Telemedicina</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados </h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">Dia Da Semana</Label>
|
||||
<div className="flex gap-4 mt-2 flex-nowrap">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="monday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Segunda-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="tuesday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Terça-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="wednesday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Quarta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="thursday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Quinta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="friday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Sexta-Feira</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="saturday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Sabado</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="weekday" value="sunday" className="text-blue-600" />
|
||||
<span className="whitespace-nowrap text-sm">Domingo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="horarioEntrada" className="text-sm font-medium text-gray-700">
|
||||
Horario De Entrada
|
||||
</Label>
|
||||
<Input type="time" id="horarioEntrada" name="horarioEntrada" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="horarioSaida" className="text-sm font-medium text-gray-700">
|
||||
Horario De Saida
|
||||
</Label>
|
||||
<Input type="time" id="horarioSaida" name="horarioSaida" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="duracaoConsulta" className="text-sm font-medium text-gray-700">
|
||||
Duração Da Consulta (min)
|
||||
</Label>
|
||||
<Input type="number" id="duracaoConsulta" name="duracaoConsulta" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modalidadeConsulta" className="text-sm font-medium text-gray-700">
|
||||
Modalidade De Consulta
|
||||
</Label>
|
||||
<Select onValueChange={(value) => setModalidadeConsulta(value)} value={modalidadeConsulta}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="presencial">Presencial </SelectItem>
|
||||
<SelectItem value="telemedicina">Telemedicina</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/doctor/disponibilidade/excecoes">
|
||||
<Button variant="outline">Adicionar Exceção</Button>
|
||||
</Link>
|
||||
<Link href="/doctor/dashboard">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
Salvar Disponibilidade
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/doctor/disponibilidade/excecoes">
|
||||
<Button variant="outline">Adicionar Exceção</Button>
|
||||
</Link>
|
||||
<Link href="/doctor/dashboard">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Disponibilidade"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/doctor/layout.tsx
Normal file
66
app/doctor/layout.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
// Caminho: app/(doctor)/layout.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Nossas importações centralizadas
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import DashboardLayout, { UserProfile } from "@/components/layout/DashboardLayout";
|
||||
import { dashboardConfig } from "@/config/dashboard.config";
|
||||
|
||||
interface DoctorLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DoctorLayout({ children }: DoctorLayoutProps) {
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthentication = async () => {
|
||||
try {
|
||||
// Busca o usuário logado
|
||||
const userData = await usuariosApi.getCurrentUser();
|
||||
// Pega a configuração específica do "doctor"
|
||||
const config = dashboardConfig.doctor;
|
||||
// Formata os dados para o perfil
|
||||
setUserProfile(config.getUserProfile(userData));
|
||||
} catch (error) {
|
||||
// Se falhar, redireciona para o login
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuthentication();
|
||||
}, [router]);
|
||||
|
||||
// Enquanto verifica, mostra uma tela de carregamento
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Verificando autenticação...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não tiver perfil (redirect em andamento), não renderiza nada
|
||||
if (!userProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pega os itens de menu da configuração
|
||||
const menuItems = dashboardConfig.doctor.menuItems;
|
||||
|
||||
// Renderiza o layout genérico com as props corretas
|
||||
return (
|
||||
<DashboardLayout menuItems={menuItems} userProfile={userProfile}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Caminho: app/(doctor)/login/page.tsx
|
||||
|
||||
import { LoginForm } from "@/components/LoginForm";
|
||||
import Link from "next/link"; // Adicionado para o link de "Voltar"
|
||||
|
||||
export default function DoctorLoginPage() {
|
||||
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
|
||||
// O ideal no futuro é deletar esta página e redirecionar os usuários.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-green-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Área do Médico</h1>
|
||||
<p className="text-muted-foreground mb-8">Acesse o sistema médico</p>
|
||||
|
||||
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
|
||||
{/* Chamando o LoginForm unificado sem props desnecessárias */}
|
||||
<LoginForm>
|
||||
{/* Adicionamos um link de "Voltar" como filho (children) */}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/">
|
||||
<span className="font-semibold text-primary hover:underline cursor-pointer">
|
||||
Voltar à página inicial
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</LoginForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,517 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback, FormEvent } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { medicosApi, Doctor } from "@/services/medicosApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||
|
||||
// Mock data - in a real app, this would come from an API
|
||||
const mockDoctors = [
|
||||
{
|
||||
id: 1,
|
||||
nome: "Dr. Carlos Silva",
|
||||
cpf: "123.456.789-00",
|
||||
rg: "12.345.678-9",
|
||||
sexo: "masculino",
|
||||
dataNascimento: "1980-05-15",
|
||||
etnia: "branca",
|
||||
raca: "caucasiana",
|
||||
naturalidade: "Aracaju",
|
||||
nacionalidade: "brasileira",
|
||||
profissao: "Médico",
|
||||
estadoCivil: "casado",
|
||||
nomeMae: "Ana Silva",
|
||||
nomePai: "José Silva",
|
||||
nomeEsposo: "Maria Silva",
|
||||
crm: "CRM/SE 12345",
|
||||
especialidade: "Cardiologia",
|
||||
email: "carlos@email.com",
|
||||
celular: "(79) 99999-1234",
|
||||
telefone1: "(79) 3214-5678",
|
||||
telefone2: "",
|
||||
cep: "49000-000",
|
||||
endereco: "Rua dos Médicos, 123",
|
||||
numero: "123",
|
||||
complemento: "Sala 101",
|
||||
bairro: "Centro",
|
||||
cidade: "Aracaju",
|
||||
estado: "SE",
|
||||
tipoSanguineo: "A+",
|
||||
peso: "80",
|
||||
altura: "1.80",
|
||||
alergias: "Nenhuma alergia conhecida",
|
||||
convenio: "Particular",
|
||||
plano: "Premium",
|
||||
numeroMatricula: "123456789",
|
||||
validadeCarteira: "2025-12-31",
|
||||
observacoes: "Médico experiente",
|
||||
},
|
||||
];
|
||||
// Usaremos Partial<Doctor> para o formulário, pois nem todos os campos são obrigatórios na edição.
|
||||
type DoctorFormData = Partial<Doctor>;
|
||||
|
||||
export default function EditarMedicoPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const doctorId = Number.parseInt(params.id as string);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome: "",
|
||||
cpf: "",
|
||||
rg: "",
|
||||
sexo: "",
|
||||
dataNascimento: "",
|
||||
etnia: "",
|
||||
raca: "",
|
||||
naturalidade: "",
|
||||
nacionalidade: "",
|
||||
profissao: "",
|
||||
estadoCivil: "",
|
||||
nomeMae: "",
|
||||
nomePai: "",
|
||||
nomeEsposo: "",
|
||||
crm: "",
|
||||
especialidade: "",
|
||||
email: "",
|
||||
celular: "",
|
||||
telefone1: "",
|
||||
telefone2: "",
|
||||
cep: "",
|
||||
endereco: "",
|
||||
numero: "",
|
||||
complemento: "",
|
||||
bairro: "",
|
||||
cidade: "",
|
||||
estado: "",
|
||||
tipoSanguineo: "",
|
||||
peso: "",
|
||||
altura: "",
|
||||
alergias: "",
|
||||
convenio: "",
|
||||
plano: "",
|
||||
numeroMatricula: "",
|
||||
validadeCarteira: "",
|
||||
observacoes: "",
|
||||
});
|
||||
const doctorId = params.id as string;
|
||||
|
||||
const [formData, setFormData] = useState<DoctorFormData>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGuiaConvenio, setIsGuiaConvenio] = useState(false);
|
||||
const [validadeIndeterminada, setValidadeIndeterminada] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load doctor data
|
||||
const doctor = mockDoctors.find((d) => d.id === doctorId);
|
||||
if (doctor) {
|
||||
setFormData(doctor);
|
||||
const fetchDoctor = useCallback(async () => {
|
||||
if (!doctorId) {
|
||||
setError("ID do médico não fornecido.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await medicosApi.getById(doctorId);
|
||||
if (data) {
|
||||
setFormData(data);
|
||||
} else {
|
||||
setError("Médico não encontrado.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar dados do médico:", e);
|
||||
setError("Não foi possível carregar os dados do médico.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
useEffect(() => {
|
||||
fetchDoctor();
|
||||
}, [fetchDoctor]);
|
||||
|
||||
const handleInputChange = (field: keyof DoctorFormData, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log("[v0] Updating doctor:", formData);
|
||||
// Here you would typically send the data to your API
|
||||
router.push("/medicos");
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await medicosApi.update(doctorId, formData);
|
||||
router.push("/medicos"); // Redireciona para a lista após o sucesso
|
||||
} catch (e) {
|
||||
console.error("Erro ao atualizar médico:", e);
|
||||
setError("Não foi possível salvar as alterações. Tente novamente.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
|
||||
Carregando dados do médico...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-600">
|
||||
<p>{error}</p>
|
||||
<Link href="/medicos">
|
||||
<Button variant="outline" className="mt-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar para a lista
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/medicos">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Editar Médico</h1>
|
||||
<p className="text-gray-600">Atualize as informações do médico</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/medicos">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Editar Médico</h1>
|
||||
<p className="text-gray-600">Atualize as informações do médico</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados Pessoais</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="full_name">Nome *</Label>
|
||||
<Input id="full_name" value={formData.full_name || ''} onChange={(e) => handleInputChange("full_name", e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF *</Label>
|
||||
<Input id="cpf" value={formData.cpf || ''} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="000.000.000-00" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rg">RG</Label>
|
||||
<Input id="rg" value={formData.rg || ''} onChange={(e) => handleInputChange("rg", e.target.value)} placeholder="00.000.000-0" />
|
||||
</div>
|
||||
{/* Outros campos do formulário seguem o mesmo padrão, usando formData.nome_da_coluna */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados Pessoais</h2>
|
||||
{/* Outras seções do formulário (Profissional, Contato, etc.) */}
|
||||
{/* Omitido para brevidade, mas a lógica de value e onChange deve seguir o padrão acima */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome">Nome *</Label>
|
||||
<Input id="nome" value={formData.nome} onChange={(e) => handleInputChange("nome", e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF *</Label>
|
||||
<Input id="cpf" value={formData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="000.000.000-00" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rg">RG</Label>
|
||||
<Input id="rg" value={formData.rg} onChange={(e) => handleInputChange("rg", e.target.value)} placeholder="00.000.000-0" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Sexo *</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="radio" id="masculino" name="sexo" value="masculino" checked={formData.sexo === "masculino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-blue-600" />
|
||||
<Label htmlFor="masculino">Masculino</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="radio" id="feminino" name="sexo" value="feminino" checked={formData.sexo === "feminino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-blue-600" />
|
||||
<Label htmlFor="feminino">Feminino</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dataNascimento">Data de nascimento *</Label>
|
||||
<Input id="dataNascimento" type="date" value={formData.dataNascimento} onChange={(e) => handleInputChange("dataNascimento", e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="etnia">Etnia</Label>
|
||||
<Select value={formData.etnia} onValueChange={(value) => handleInputChange("etnia", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="branca">Branca</SelectItem>
|
||||
<SelectItem value="preta">Preta</SelectItem>
|
||||
<SelectItem value="parda">Parda</SelectItem>
|
||||
<SelectItem value="amarela">Amarela</SelectItem>
|
||||
<SelectItem value="indigena">Indígena</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="raca">Raça</Label>
|
||||
<Select value={formData.raca} onValueChange={(value) => handleInputChange("raca", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="caucasiana">Caucasiana</SelectItem>
|
||||
<SelectItem value="negroide">Negroide</SelectItem>
|
||||
<SelectItem value="mongoloide">Mongoloide</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="naturalidade">Naturalidade</Label>
|
||||
<Input id="naturalidade" value={formData.naturalidade} onChange={(e) => handleInputChange("naturalidade", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nacionalidade">Nacionalidade</Label>
|
||||
<Select value={formData.nacionalidade} onValueChange={(value) => handleInputChange("nacionalidade", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brasileira">Brasileira</SelectItem>
|
||||
<SelectItem value="estrangeira">Estrangeira</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profissao">Profissão</Label>
|
||||
<Input id="profissao" value={formData.profissao} onChange={(e) => handleInputChange("profissao", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estadoCivil">Estado civil</Label>
|
||||
<Select value={formData.estadoCivil} onValueChange={(value) => handleInputChange("estadoCivil", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solteiro">Solteiro(a)</SelectItem>
|
||||
<SelectItem value="casado">Casado(a)</SelectItem>
|
||||
<SelectItem value="divorciado">Divorciado(a)</SelectItem>
|
||||
<SelectItem value="viuvo">Viúvo(a)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nomeMae">Nome da mãe</Label>
|
||||
<Input id="nomeMae" value={formData.nomeMae} onChange={(e) => handleInputChange("nomeMae", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nomePai">Nome do pai</Label>
|
||||
<Input id="nomePai" value={formData.nomePai} onChange={(e) => handleInputChange("nomePai", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nomeEsposo">Nome do esposo(a)</Label>
|
||||
<Input id="nomeEsposo" value={formData.nomeEsposo} onChange={(e) => handleInputChange("nomeEsposo", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="guiaConvenio" checked={isGuiaConvenio} onCheckedChange={(checked) => setIsGuiaConvenio(checked === true)} />
|
||||
<Label htmlFor="guiaConvenio">RN na Guia do convênio</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea id="observacoes" value={formData.observacoes} onChange={(e) => handleInputChange("observacoes", e.target.value)} placeholder="Digite observações sobre o médico..." className="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional Information Section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Informações Profissionais</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crm">CRM *</Label>
|
||||
<Input id="crm" value={formData.crm} onChange={(e) => handleInputChange("crm", e.target.value)} placeholder="CRM/UF 12345" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="especialidade">Especialidade *</Label>
|
||||
<Select value={formData.especialidade} onValueChange={(value) => handleInputChange("especialidade", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a especialidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cardiologia">Cardiologia</SelectItem>
|
||||
<SelectItem value="Pediatria">Pediatria</SelectItem>
|
||||
<SelectItem value="Ortopedia">Ortopedia</SelectItem>
|
||||
<SelectItem value="Neurologia">Neurologia</SelectItem>
|
||||
<SelectItem value="Ginecologia">Ginecologia</SelectItem>
|
||||
<SelectItem value="Dermatologia">Dermatologia</SelectItem>
|
||||
<SelectItem value="Psiquiatria">Psiquiatria</SelectItem>
|
||||
<SelectItem value="Oftalmologia">Oftalmologia</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Contato</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" type="email" value={formData.email} onChange={(e) => handleInputChange("email", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="celular">Celular</Label>
|
||||
<Input id="celular" value={formData.celular} onChange={(e) => handleInputChange("celular", e.target.value)} placeholder="(00) 00000-0000" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone1">Telefone 1</Label>
|
||||
<Input id="telefone1" value={formData.telefone1} onChange={(e) => handleInputChange("telefone1", e.target.value)} placeholder="(00) 0000-0000" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone2">Telefone 2</Label>
|
||||
<Input id="telefone2" value={formData.telefone2} onChange={(e) => handleInputChange("telefone2", e.target.value)} placeholder="(00) 0000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Endereço</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input id="cep" value={formData.cep} onChange={(e) => handleInputChange("cep", e.target.value)} placeholder="00000-000" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endereco">Endereço</Label>
|
||||
<Input id="endereco" value={formData.endereco} onChange={(e) => handleInputChange("endereco", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero">Número</Label>
|
||||
<Input id="numero" value={formData.numero} onChange={(e) => handleInputChange("numero", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input id="complemento" value={formData.complemento} onChange={(e) => handleInputChange("complemento", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input id="bairro" value={formData.bairro} onChange={(e) => handleInputChange("bairro", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input id="cidade" value={formData.cidade} onChange={(e) => handleInputChange("cidade", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estado">Estado</Label>
|
||||
<Select value={formData.estado} onValueChange={(value) => handleInputChange("estado", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AC">Acre</SelectItem>
|
||||
<SelectItem value="AL">Alagoas</SelectItem>
|
||||
<SelectItem value="AP">Amapá</SelectItem>
|
||||
<SelectItem value="AM">Amazonas</SelectItem>
|
||||
<SelectItem value="BA">Bahia</SelectItem>
|
||||
<SelectItem value="CE">Ceará</SelectItem>
|
||||
<SelectItem value="DF">Distrito Federal</SelectItem>
|
||||
<SelectItem value="ES">Espírito Santo</SelectItem>
|
||||
<SelectItem value="GO">Goiás</SelectItem>
|
||||
<SelectItem value="MA">Maranhão</SelectItem>
|
||||
<SelectItem value="MT">Mato Grosso</SelectItem>
|
||||
<SelectItem value="MS">Mato Grosso do Sul</SelectItem>
|
||||
<SelectItem value="MG">Minas Gerais</SelectItem>
|
||||
<SelectItem value="PA">Pará</SelectItem>
|
||||
<SelectItem value="PB">Paraíba</SelectItem>
|
||||
<SelectItem value="PR">Paraná</SelectItem>
|
||||
<SelectItem value="PE">Pernambuco</SelectItem>
|
||||
<SelectItem value="PI">Piauí</SelectItem>
|
||||
<SelectItem value="RJ">Rio de Janeiro</SelectItem>
|
||||
<SelectItem value="RN">Rio Grande do Norte</SelectItem>
|
||||
<SelectItem value="RS">Rio Grande do Sul</SelectItem>
|
||||
<SelectItem value="RO">Rondônia</SelectItem>
|
||||
<SelectItem value="RR">Roraima</SelectItem>
|
||||
<SelectItem value="SC">Santa Catarina</SelectItem>
|
||||
<SelectItem value="SP">São Paulo</SelectItem>
|
||||
<SelectItem value="SE">Sergipe</SelectItem>
|
||||
<SelectItem value="TO">Tocantins</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medical Information Section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Informações Médicas</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tipoSanguineo">Tipo Sanguíneo</Label>
|
||||
<Select value={formData.tipoSanguineo} onValueChange={(value) => handleInputChange("tipoSanguineo", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A+">A+</SelectItem>
|
||||
<SelectItem value="A-">A-</SelectItem>
|
||||
<SelectItem value="B+">B+</SelectItem>
|
||||
<SelectItem value="B-">B-</SelectItem>
|
||||
<SelectItem value="AB+">AB+</SelectItem>
|
||||
<SelectItem value="AB-">AB-</SelectItem>
|
||||
<SelectItem value="O+">O+</SelectItem>
|
||||
<SelectItem value="O-">O-</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="peso">Peso (kg)</Label>
|
||||
<Input id="peso" type="number" value={formData.peso} onChange={(e) => handleInputChange("peso", e.target.value)} placeholder="0.0" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="altura">Altura (m)</Label>
|
||||
<Input id="altura" type="number" step="0.01" value={formData.altura} onChange={(e) => handleInputChange("altura", e.target.value)} placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>IMC</Label>
|
||||
<Input value={formData.peso && formData.altura ? (Number.parseFloat(formData.peso) / Number.parseFloat(formData.altura) ** 2).toFixed(2) : ""} disabled placeholder="Calculado automaticamente" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Label htmlFor="alergias">Alergias</Label>
|
||||
<Textarea id="alergias" value={formData.alergias} onChange={(e) => handleInputChange("alergias", e.target.value)} placeholder="Ex: AAS, Dipirona, etc." className="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insurance Information Section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Informações de convênio</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="convenio">Convênio</Label>
|
||||
<Select value={formData.convenio} onValueChange={(value) => handleInputChange("convenio", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Particular">Particular</SelectItem>
|
||||
<SelectItem value="SUS">SUS</SelectItem>
|
||||
<SelectItem value="Unimed">Unimed</SelectItem>
|
||||
<SelectItem value="Bradesco">Bradesco Saúde</SelectItem>
|
||||
<SelectItem value="Amil">Amil</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plano">Plano</Label>
|
||||
<Input id="plano" value={formData.plano} onChange={(e) => handleInputChange("plano", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numeroMatricula">Nº de matrícula</Label>
|
||||
<Input id="numeroMatricula" value={formData.numeroMatricula} onChange={(e) => handleInputChange("numeroMatricula", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validadeCarteira">Validade da Carteira</Label>
|
||||
<Input id="validadeCarteira" type="date" value={formData.validadeCarteira} onChange={(e) => handleInputChange("validadeCarteira", e.target.value)} disabled={validadeIndeterminada} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="validadeIndeterminada" checked={validadeIndeterminada} onCheckedChange={(checked) => setValidadeIndeterminada(checked === true)} />
|
||||
<Label htmlFor="validadeIndeterminada">Validade Indeterminada</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/medicos">
|
||||
<Button type="button" variant="outline">
|
||||
Cancelar
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Salvar Alterações
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href="/medicos">
|
||||
<Button type="button" variant="outline" disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
</Link>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,8 @@
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@ -12,11 +11,29 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, Clock, User, Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import TiptapEditor from "@/components/ui/tiptap-editor";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { reportsApi } from "@/services/reportsApi.mjs";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import Link from "next/link";
|
||||
|
||||
// [CORREÇÃO] Adicionando a importação que faltava para o AlertDialog
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
// [TIPAGEM] Importando tipos da camada de serviço
|
||||
import { relatoriosApi, Report } from "@/services/relatoriosApi";
|
||||
import { disponibilidadeApi, Availability } from "@/services/disponibilidadeApi";
|
||||
import { excecoesApi, Exception } from "@/services/excecoesApi";
|
||||
|
||||
export default function EditarLaudoPage() {
|
||||
const router = useRouter();
|
||||
@ -24,66 +41,144 @@ export default function EditarLaudoPage() {
|
||||
const patientId = params.id as string;
|
||||
const laudoId = params.laudoId as string;
|
||||
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
// [ESTADO] & [TIPAGEM] Estados robustos e tipados
|
||||
const [formData, setFormData] = useState<Partial<Report>>({});
|
||||
const [availability, setAvailability] = useState<Availability[]>([]);
|
||||
const [exceptions, setExceptions] = useState<Exception[]>([]);
|
||||
const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [exceptionToDelete, setExceptionToDelete] = useState<string | null>(null);
|
||||
|
||||
const formatTime = (time: string) => (time ? time.split(":").slice(0, 2).join(":") : "");
|
||||
|
||||
const weekdaysPT: Record<string, string> = {
|
||||
sunday: "Domingo",
|
||||
monday: "Segunda",
|
||||
tuesday: "Terça",
|
||||
wednesday: "Quarta",
|
||||
thursday: "Quinta",
|
||||
friday: "Sexta",
|
||||
saturday: "Sábado",
|
||||
};
|
||||
|
||||
// [API] & [ESTADO] Lógica de busca de dados centralizada e robusta
|
||||
useEffect(() => {
|
||||
if (!laudoId) {
|
||||
setIsLoading(false);
|
||||
setError("ID do laudo não encontrado.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// TODO: Remover ID fixo e obter do usuário logado
|
||||
const doctorId = "3bb9ee4a-cfdd-4d81-b628-383907dfa225";
|
||||
|
||||
const [reportData, availabilityResponse, exceptionsResponse] = await Promise.all([
|
||||
relatoriosApi.getById(laudoId),
|
||||
disponibilidadeApi.list(),
|
||||
excecoesApi.list(),
|
||||
]);
|
||||
|
||||
if (reportData) {
|
||||
setFormData({
|
||||
...reportData,
|
||||
due_at: reportData.due_at ? new Date(reportData.due_at) : null,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Laudo não encontrado.");
|
||||
}
|
||||
|
||||
setAvailability(availabilityResponse.filter(disp => disp.doctor_id === doctorId));
|
||||
setExceptions(exceptionsResponse.filter(exc => exc.doctor_id === doctorId));
|
||||
|
||||
} catch (e: any) {
|
||||
setError("Falha ao carregar os dados. Por favor, tente novamente.");
|
||||
console.error("Failed to fetch data:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [laudoId]);
|
||||
|
||||
const openDeleteDialog = (exceptionId: string) => {
|
||||
setExceptionToDelete(exceptionId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteException = async (exceptionId: string) => {
|
||||
try {
|
||||
await excecoesApi.delete(exceptionId);
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: "Exceção deletada com sucesso.",
|
||||
});
|
||||
setExceptions((prev) => prev.filter((p) => p.id !== exceptionId));
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: e?.message || "Não foi possível deletar a exceção.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setExceptionToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
function formatAvailability(data: Availability[]) {
|
||||
return data.reduce(
|
||||
(acc: Record<string, { start: string; end: string }[]>, item) => {
|
||||
const { weekday, start_time, end_time } = item;
|
||||
if (!acc[weekday]) {
|
||||
acc[weekday] = [];
|
||||
}
|
||||
acc[weekday].push({ start: start_time, end: end_time });
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (laudoId) {
|
||||
setLoading(true);
|
||||
reportsApi.getReportById(laudoId)
|
||||
.then((data: any) => {
|
||||
console.log("Fetched report data:", data);
|
||||
// The API now returns an array, get the first element
|
||||
const reportData = Array.isArray(data) && data.length > 0 ? data[0] : null;
|
||||
if (reportData) {
|
||||
setFormData({
|
||||
...reportData,
|
||||
due_at: reportData.due_at ? new Date(reportData.due_at) : null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to fetch report details:", error);
|
||||
// Here you could add a toast notification to inform the user
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// If there's no laudoId, we shouldn't be in a loading state.
|
||||
setLoading(false);
|
||||
if (availability.length) {
|
||||
const formatted = formatAvailability(availability);
|
||||
setSchedule(formatted);
|
||||
}
|
||||
}, [laudoId]);
|
||||
}, [availability]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { id, value } = e.target;
|
||||
setFormData((prev: any) => ({ ...prev, [id]: value }));
|
||||
setFormData((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSelectChange = (id: string, value: string) => {
|
||||
setFormData((prev: any) => ({ ...prev, [id]: value }));
|
||||
setFormData((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (id: string, checked: boolean) => {
|
||||
setFormData((prev: any) => ({ ...prev, [id]: checked }));
|
||||
setFormData((prev) => ({ ...prev, [id]: checked }));
|
||||
};
|
||||
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
console.log("Date selected:", date);
|
||||
if (date) {
|
||||
setFormData((prev: any) => ({ ...prev, due_at: date }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
handleDateChange(date);
|
||||
setIsDatePickerOpen(false); // Close the dialog after selection
|
||||
if (date) {
|
||||
setFormData((prev) => ({ ...prev, due_at: date }));
|
||||
}
|
||||
setIsDatePickerOpen(false);
|
||||
};
|
||||
|
||||
const handleEditorChange = (html: string, json: object) => {
|
||||
setFormData((prev: any) => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
content_html: html,
|
||||
content_json: json
|
||||
@ -95,143 +190,151 @@ export default function EditarLaudoPage() {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { id, patient_id, created_at, updated_at, created_by, updated_by, ...updateData } = formData;
|
||||
await reportsApi.updateReport(laudoId, updateData);
|
||||
// toast({ title: "Laudo atualizado com sucesso!" });
|
||||
await relatoriosApi.update(laudoId, updateData);
|
||||
toast({ title: "Laudo atualizado com sucesso!" });
|
||||
router.push(`/doctor/medicos/${patientId}/laudos`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update laudo", error);
|
||||
// toast({ title: "Erro ao atualizar laudo", variant: "destructive" });
|
||||
toast({ title: "Erro ao atualizar laudo", variant: "destructive" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
// [UI] Feedback Visual
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="container mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-24 w-full" /></div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-40 w-full" /></div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="container mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Editar Laudo - {formData.order_number}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="order_number">Nº do Pedido</Label>
|
||||
<Input id="order_number" value={formData.order_number || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="exam">Exame</Label>
|
||||
<Input id="exam" value={formData.exam || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diagnosis">Diagnóstico</Label>
|
||||
<Input id="diagnosis" value={formData.diagnosis || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cid_code">Código CID</Label>
|
||||
<Input id="cid_code" value={formData.cid_code || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested_by">Solicitado Por</Label>
|
||||
<Input id="requested_by" value={formData.requested_by || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select onValueChange={(value) => handleSelectChange("status", value)} value={formData.status}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Rascunho</SelectItem>
|
||||
<SelectItem value="final">Finalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_at">Data de Vencimento</Label>
|
||||
<Dialog open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.due_at ? format(new Date(formData.due_at), "PPP") : <span>Escolha uma data</span>}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.due_at ? new Date(formData.due_at) : undefined}
|
||||
onSelect={handleDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conclusion">Conclusão</Label>
|
||||
<Textarea id="conclusion" value={formData.conclusion || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Conteúdo do Laudo</Label>
|
||||
<div className="rounded-md border border-input">
|
||||
<TiptapEditor content={formData.content_html || ''} onChange={handleEditorChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
|
||||
<Label htmlFor="hide_date">Ocultar Data</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
|
||||
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<CardHeader><Skeleton className="h-8 w-1/4" /></CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-40 w-full" /></div>
|
||||
<div className="flex justify-end space-x-2"><Skeleton className="h-10 w-24" /><Skeleton className="h-10 w-24" /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="container mx-auto p-4 text-red-600">Erro: {error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Editar Laudo - {formData.order_number}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="order_number">Nº do Pedido</Label>
|
||||
<Input id="order_number" value={formData.order_number || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="exam">Exame</Label>
|
||||
<Input id="exam" value={formData.exam || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diagnosis">Diagnóstico</Label>
|
||||
<Input id="diagnosis" value={formData.diagnosis || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cid_code">Código CID</Label>
|
||||
<Input id="cid_code" value={formData.cid_code || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested_by">Solicitado Por</Label>
|
||||
<Input id="requested_by" value={formData.requested_by || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select onValueChange={(value) => handleSelectChange("status", value)} value={formData.status}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Rascunho</SelectItem>
|
||||
<SelectItem value="final">Finalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_at">Data de Vencimento</Label>
|
||||
<Dialog open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.due_at ? format(new Date(formData.due_at), "PPP") : <span>Escolha uma data</span>}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.due_at ? new Date(formData.due_at) : undefined}
|
||||
onSelect={handleDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conclusion">Conclusão</Label>
|
||||
<Textarea id="conclusion" value={formData.conclusion || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Conteúdo do Laudo</Label>
|
||||
<div className="rounded-md border border-input">
|
||||
<TiptapEditor content={formData.content_html || ''} onChange={handleEditorChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
|
||||
<Label htmlFor="hide_date">Ocultar Data</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
|
||||
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>Tem certeza que deseja excluir esta exceção? Esta ação não pode ser desfeita.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => exceptionToDelete && handleDeleteException(exceptionToDelete)} className="bg-red-600 hover:bg-red-700">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -15,56 +14,55 @@ import { Calendar } from "@/components/ui/calendar";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import TiptapEditor from "@/components/ui/tiptap-editor";
|
||||
import { relatoriosApi, Report } from "@/services/relatoriosApi";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
import { reportsApi } from "@/services/reportsApi.mjs";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
|
||||
|
||||
|
||||
// Definindo o tipo para a criação de um relatório, omitindo o 'id' que é gerado pelo banco.
|
||||
type ReportInput = Omit<Report, 'id'>;
|
||||
|
||||
export default function NovoLaudoPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const patientId = params.id as string;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
const [formData, setFormData] = useState<Partial<ReportInput>>({
|
||||
order_number: "",
|
||||
exam: "",
|
||||
diagnosis: "",
|
||||
conclusion: "",
|
||||
cid_code: "",
|
||||
content_html: "",
|
||||
content_json: {}, // Added for the JSON content from the editor
|
||||
content_json: {},
|
||||
status: "draft",
|
||||
requested_by: "",
|
||||
due_at: new Date(),
|
||||
due_at: new Date().toISOString(),
|
||||
hide_date: false,
|
||||
hide_signature: false,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { id, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [id]: value }));
|
||||
setFormData((prev: Partial<ReportInput>) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSelectChange = (id: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [id]: value }));
|
||||
setFormData((prev: Partial<ReportInput>) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (id: string, checked: boolean) => {
|
||||
setFormData(prev => ({ ...prev, [id]: checked }));
|
||||
setFormData((prev: Partial<ReportInput>) => ({ ...prev, [id]: checked }));
|
||||
};
|
||||
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
setFormData(prev => ({ ...prev, due_at: date }));
|
||||
setFormData((prev: Partial<ReportInput>) => ({ ...prev, due_at: date.toISOString() }));
|
||||
}
|
||||
};
|
||||
|
||||
// Updated to handle both HTML and JSON from the editor
|
||||
const handleEditorChange = (html: string, json: object) => {
|
||||
setFormData(prev => ({
|
||||
setFormData((prev: Partial<ReportInput>) => ({
|
||||
...prev,
|
||||
content_html: html,
|
||||
content_json: json
|
||||
@ -74,121 +72,126 @@ export default function NovoLaudoPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const laudoData = {
|
||||
...formData,
|
||||
patient_id: patientId,
|
||||
due_at: formData.due_at.toISOString(), // Ensure date is in ISO format for the API
|
||||
};
|
||||
} as ReportInput;
|
||||
|
||||
await reportsApi.createReport(laudoData);
|
||||
await relatoriosApi.create(laudoData);
|
||||
|
||||
// You can use a toast notification here for better user feedback
|
||||
// toast({ title: "Laudo criado com sucesso!" });
|
||||
toast({
|
||||
title: "Sucesso!",
|
||||
description: "Laudo criado com sucesso."
|
||||
});
|
||||
|
||||
router.push(`/doctor/medicos/${patientId}/laudos`);
|
||||
router.push(`/doctor/pacientes/${patientId}/laudos`);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create laudo", error);
|
||||
// You can use a toast notification for errors
|
||||
// toast({ title: "Erro ao criar laudo", description: error.message, variant: "destructive" });
|
||||
const errorMessage = error.message || "Não foi possível criar o laudo. Tente novamente.";
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: "Erro ao criar laudo",
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="container mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Criar Novo Laudo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="order_number">Nº do Pedido</Label>
|
||||
<Input id="order_number" value={formData.order_number} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="exam">Exame</Label>
|
||||
<Input id="exam" value={formData.exam} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diagnosis">Diagnóstico</Label>
|
||||
<Input id="diagnosis" value={formData.diagnosis} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cid_code">Código CID</Label>
|
||||
<Input id="cid_code" value={formData.cid_code} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested_by">Solicitado Por</Label>
|
||||
<Input id="requested_by" value={formData.requested_by} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select onValueChange={(value) => handleSelectChange("status", value)} defaultValue={formData.status}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Rascunho</SelectItem>
|
||||
<SelectItem value="final">Finalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_at">Data de Vencimento</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.due_at ? format(formData.due_at, "PPP") : <span>Escolha uma data</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar mode="single" selected={formData.due_at} onSelect={handleDateChange} initialFocus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Criar Novo Laudo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && <p className="text-red-500 text-center font-medium">{error}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conclusion">Conclusão</Label>
|
||||
<Textarea id="conclusion" value={formData.conclusion} onChange={handleInputChange} />
|
||||
<Label htmlFor="order_number">Nº do Pedido</Label>
|
||||
<Input id="order_number" value={formData.order_number || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Conteúdo do Laudo</Label>
|
||||
<div className="rounded-md border border-input">
|
||||
<TiptapEditor content={formData.content_html} onChange={handleEditorChange} />
|
||||
</div>
|
||||
<Label htmlFor="exam">Exame</Label>
|
||||
<Input id="exam" value={formData.exam || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diagnosis">Diagnóstico</Label>
|
||||
<Input id="diagnosis" value={formData.diagnosis || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cid_code">Código CID</Label>
|
||||
<Input id="cid_code" value={formData.cid_code || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested_by">Solicitado Por</Label>
|
||||
<Input id="requested_by" value={formData.requested_by || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select onValueChange={(value) => handleSelectChange("status", value)} defaultValue={formData.status}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Rascunho</SelectItem>
|
||||
<SelectItem value="final">Finalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_at">Data de Vencimento</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.due_at ? format(new Date(formData.due_at), "PPP") : <span>Escolha uma data</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar mode="single" selected={formData.due_at ? new Date(formData.due_at) : undefined} onSelect={handleDateChange} initialFocus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conclusion">Conclusão</Label>
|
||||
<Textarea id="conclusion" value={formData.conclusion || ''} onChange={handleInputChange} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
|
||||
<Label htmlFor="hide_date">Ocultar Data</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
|
||||
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Conteúdo do Laudo</Label>
|
||||
<div className="rounded-md border border-input">
|
||||
<TiptapEditor content={formData.content_html || ''} onChange={handleEditorChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Laudo"}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
|
||||
<Label htmlFor="hide_date">Ocultar Data</Label>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
|
||||
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar Laudo"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,39 +1,58 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { api } from '@/services/api.mjs';
|
||||
import { reportsApi } from '@/services/reportsApi.mjs';
|
||||
import DoctorLayout from '@/components/doctor-layout';
|
||||
import type React from "react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
// [TIPAGEM] Interfaces importadas dos serviços
|
||||
import { pacientesApi, Patient } from "@/services/pacientesApi";
|
||||
import { relatoriosApi, Report } from "@/services/relatoriosApi";
|
||||
|
||||
export default function LaudosPage() {
|
||||
const [patient, setPatient] = useState(null);
|
||||
const [laudos, setLaudos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const params = useParams();
|
||||
const patientId = params.id as string;
|
||||
|
||||
// [ESTADO] Gerenciamento de estado robusto
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
const [laudos, setLaudos] = useState<Report[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(5);
|
||||
|
||||
// [API] Lógica de busca de dados corrigida
|
||||
useEffect(() => {
|
||||
if (patientId) {
|
||||
const fetchPatientAndLaudos = async () => {
|
||||
setLoading(true);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const patientData = await api.get(`/rest/v1/patients?id=eq.${patientId}&select=*`).then(r => r?.[0]);
|
||||
setPatient(patientData);
|
||||
const [patientData, allLaudosData] = await Promise.all([
|
||||
pacientesApi.getById(patientId),
|
||||
relatoriosApi.list() // Corrigido: Chama a função sem argumentos
|
||||
]);
|
||||
|
||||
const laudosData = await reportsApi.getReports(patientId);
|
||||
setLaudos(laudosData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data:", error);
|
||||
// Filtra os laudos para o paciente específico no lado do cliente
|
||||
const patientLaudos = allLaudosData.filter(laudo => laudo.patient_id === patientId);
|
||||
|
||||
setPatient(patientData);
|
||||
setLaudos(patientLaudos);
|
||||
} catch (err: any) {
|
||||
const errorMessage = "Falha ao buscar os dados do paciente e seus laudos.";
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: err.message || errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -46,83 +65,84 @@ export default function LaudosPage() {
|
||||
const currentItems = laudos.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(laudos.length / itemsPerPage);
|
||||
|
||||
const paginate = (pageNumber) => setCurrentPage(pageNumber);
|
||||
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||
|
||||
// [UI] Feedback Visual para Carregamento e Erro
|
||||
if (isLoading) {
|
||||
return <div className="container mx-auto p-4 text-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="container mx-auto p-4 text-center text-red-600">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="container mx-auto p-4">
|
||||
{loading ? (
|
||||
<p>Carregando...</p>
|
||||
) : (
|
||||
<>
|
||||
{patient && (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Informações do Paciente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p><strong>Nome:</strong> {patient.full_name}</p>
|
||||
<p><strong>Email:</strong> {patient.email}</p>
|
||||
<p><strong>Telefone:</strong> {patient.phone_mobile}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Laudos do Paciente</CardTitle>
|
||||
<Link href={`/doctor/medicos/${patientId}/laudos/novo`}>
|
||||
<Button>Criar Novo Laudo</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nº do Pedido</TableHead>
|
||||
<TableHead>Exame</TableHead>
|
||||
<TableHead>Diagnóstico</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data de Criação</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentItems.length > 0 ? (
|
||||
currentItems.map((laudo) => (
|
||||
<TableRow key={laudo.id}>
|
||||
<TableCell>{laudo.order_number}</TableCell>
|
||||
<TableCell>{laudo.exam}</TableCell>
|
||||
<TableCell>{laudo.diagnosis}</TableCell>
|
||||
<TableCell>{laudo.status}</TableCell>
|
||||
<TableCell>{new Date(laudo.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/doctor/medicos/${patientId}/laudos/${laudo.id}/editar`}>
|
||||
<Button variant="outline" size="sm">Editar</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center">Nenhum laudo encontrado.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2 mt-4 p-4">
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<Button key={i} onClick={() => paginate(i + 1)} variant={currentPage === i + 1 ? 'default' : 'outline'}>
|
||||
{i + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
<div className="container mx-auto p-4">
|
||||
{patient && (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Informações do Paciente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p><strong>Nome:</strong> {patient.full_name}</p>
|
||||
<p><strong>Email:</strong> {patient.email}</p>
|
||||
<p><strong>Telefone:</strong> {patient.phone_mobile}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Laudos do Paciente</CardTitle>
|
||||
<Link href={`/doctor/medicos/${patientId}/laudos/novo`}>
|
||||
<Button>Criar Novo Laudo</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nº do Pedido</TableHead>
|
||||
<TableHead>Exame</TableHead>
|
||||
<TableHead>Diagnóstico</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data de Criação</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentItems.length > 0 ? (
|
||||
currentItems.map((laudo) => (
|
||||
<TableRow key={laudo.id}>
|
||||
<TableCell>{laudo.order_number}</TableCell>
|
||||
<TableCell>{laudo.exam}</TableCell>
|
||||
<TableCell>{laudo.diagnosis}</TableCell>
|
||||
<TableCell>{laudo.status}</TableCell>
|
||||
<TableCell>{new Date(laudo.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/doctor/medicos/${patientId}/laudos/${laudo.id}/editar`}>
|
||||
<Button variant="outline" size="sm">Editar</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center">Nenhum laudo encontrado.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2 mt-4 p-4">
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<Button key={i} onClick={() => paginate(i + 1)} variant={currentPage === i + 1 ? 'default' : 'outline'}>
|
||||
{i + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,272 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { agendamentosApi, Appointment } from "@/services/agendamentosApi";
|
||||
import { Patient } from "@/services/pacientesApi";
|
||||
import { Doctor } from "@/services/medicosApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Clock, Calendar as CalendarIcon, MapPin, Phone, User, X, RefreshCw } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Clock, Calendar as CalendarIcon, MapPin, Phone, User, X, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// IMPORTAR O COMPONENTE CALENDÁRIO DA SHADCN
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { format } from "date-fns"; // Usaremos o date-fns para formatação e comparação de datas
|
||||
import { format, parseISO, isSameDay } from "date-fns";
|
||||
|
||||
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
|
||||
|
||||
// --- TIPAGEM DA CONSULTA SALVA NO LOCALSTORAGE ---
|
||||
interface LocalStorageAppointment {
|
||||
id: number;
|
||||
patientName: string;
|
||||
doctor: string;
|
||||
specialty: string;
|
||||
date: string; // Data no formato YYYY-MM-DD
|
||||
time: string; // Hora no formato HH:MM
|
||||
status: "agendada" | "confirmada" | "cancelada" | "realizada";
|
||||
location: string;
|
||||
phone: string;
|
||||
// Interface corrigida para incluir os tipos completos de Patient e Doctor
|
||||
interface AppointmentWithDetails extends Appointment {
|
||||
patients: Patient;
|
||||
doctors: Doctor;
|
||||
}
|
||||
|
||||
const LOGGED_IN_DOCTOR_NAME = "Dr. João Santos";
|
||||
|
||||
// Função auxiliar para comparar se duas datas (Date objects) são o mesmo dia
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate();
|
||||
};
|
||||
|
||||
// --- COMPONENTE PRINCIPAL ---
|
||||
|
||||
export default function DoctorAppointmentsPage() {
|
||||
const [allAppointments, setAllAppointments] = useState<LocalStorageAppointment[]>([]);
|
||||
const [filteredAppointments, setFilteredAppointments] = useState<LocalStorageAppointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [allAppointments, setAllAppointments] = useState<AppointmentWithDetails[]>([]);
|
||||
const [filteredAppointments, setFilteredAppointments] = useState<AppointmentWithDetails[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bookedDays, setBookedDays] = useState<Date[]>([]);
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
// NOVO ESTADO 1: Armazena os dias com consultas (para o calendário)
|
||||
const [bookedDays, setBookedDays] = useState<Date[]>([]);
|
||||
const fetchAppointments = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// A camada de serviço deve ser ajustada para buscar os dados aninhados
|
||||
// Ex: api.get('/rest/v1/appointments?select=*,patients(*),doctors(*)')
|
||||
const data = await agendamentosApi.list() as AppointmentWithDetails[];
|
||||
setAllAppointments(data || []);
|
||||
|
||||
// NOVO ESTADO 2: Armazena a data selecionada no calendário
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
|
||||
const uniqueBookedDates = Array.from(new Set(data.map(app => app.scheduled_at.split('T')[0])));
|
||||
const dateObjects = uniqueBookedDates.map(dateString => parseISO(dateString));
|
||||
setBookedDays(dateObjects);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, []);
|
||||
toast.success("Agenda atualizada com sucesso!");
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar a agenda:", e);
|
||||
setError("Não foi possível carregar sua agenda. Verifique a conexão.");
|
||||
setAllAppointments([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Efeito para filtrar a lista sempre que o calendário ou a lista completa for atualizada
|
||||
useEffect(() => {
|
||||
if (selectedCalendarDate) {
|
||||
const dateString = format(selectedCalendarDate, 'yyyy-MM-dd');
|
||||
useEffect(() => {
|
||||
fetchAppointments();
|
||||
}, [fetchAppointments]);
|
||||
|
||||
// Filtra a lista completa de agendamentos pela data selecionada
|
||||
const todayAppointments = allAppointments
|
||||
.filter(app => app.date === dateString)
|
||||
.sort((a, b) => a.time.localeCompare(b.time)); // Ordena por hora
|
||||
useEffect(() => {
|
||||
if (selectedCalendarDate) {
|
||||
const todayAppointments = allAppointments
|
||||
.filter(app => isSameDay(parseISO(app.scheduled_at), selectedCalendarDate))
|
||||
.sort((a, b) => a.scheduled_at.localeCompare(b.scheduled_at));
|
||||
setFilteredAppointments(todayAppointments);
|
||||
} else {
|
||||
const todayAppointments = allAppointments
|
||||
.filter(app => isSameDay(parseISO(app.scheduled_at), new Date()))
|
||||
.sort((a, b) => a.scheduled_at.localeCompare(b.scheduled_at));
|
||||
setFilteredAppointments(todayAppointments);
|
||||
}
|
||||
}, [allAppointments, selectedCalendarDate]);
|
||||
|
||||
setFilteredAppointments(todayAppointments);
|
||||
} else {
|
||||
// Se nenhuma data estiver selecionada (ou se for limpa), mostra todos (ou os de hoje)
|
||||
const todayDateString = format(new Date(), 'yyyy-MM-dd');
|
||||
const todayAppointments = allAppointments
|
||||
.filter(app => app.date === todayDateString)
|
||||
.sort((a, b) => a.time.localeCompare(b.time));
|
||||
const getStatusVariant = (status: Appointment['status']) => {
|
||||
switch (status) {
|
||||
case "confirmed":
|
||||
case "requested":
|
||||
return "default";
|
||||
case "completed":
|
||||
return "secondary";
|
||||
case "cancelled":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
setFilteredAppointments(todayAppointments);
|
||||
}
|
||||
}, [allAppointments, selectedCalendarDate]);
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await agendamentosApi.update(id, { status: "cancelled" });
|
||||
toast.info(`Consulta cancelada com sucesso.`);
|
||||
await fetchAppointments();
|
||||
} catch (error) {
|
||||
console.error("Erro ao cancelar consulta:", error);
|
||||
toast.error("Não foi possível cancelar a consulta.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadAppointments = () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
|
||||
const allAppts: LocalStorageAppointment[] = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
|
||||
const handleReSchedule = (id: string) => {
|
||||
toast.info(`Reagendamento da Consulta ID: ${id}. Navegar para a página de agendamento.`);
|
||||
};
|
||||
|
||||
// ***** NENHUM FILTRO POR MÉDICO AQUI (Como solicitado) *****
|
||||
const appointmentsToShow = allAppts;
|
||||
const displayDate = selectedCalendarDate
|
||||
? format(selectedCalendarDate, "EEEE, dd 'de' MMMM")
|
||||
: "Selecione uma data";
|
||||
|
||||
// 1. EXTRAI E PREPARA AS DATAS PARA O CALENDÁRIO
|
||||
const uniqueBookedDates = Array.from(new Set(appointmentsToShow.map(app => app.date)));
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Agenda Médica</h1>
|
||||
<p className="text-gray-600">Visualize e gerencie todas as suas consultas.</p>
|
||||
</div>
|
||||
|
||||
// Converte YYYY-MM-DD para objetos Date, garantindo que o tempo seja meia-noite (00:00:00)
|
||||
const dateObjects = uniqueBookedDates.map(dateString => new Date(dateString + 'T00:00:00'));
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Consultas para: {displayDate}</h2>
|
||||
<Button onClick={fetchAppointments} disabled={isLoading} variant="outline" size="sm">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Atualizar Agenda
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
setAllAppointments(appointmentsToShow);
|
||||
setBookedDays(dateObjects);
|
||||
toast.success("Agenda atualizada com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar a agenda do LocalStorage:", error);
|
||||
toast.error("Não foi possível carregar sua agenda.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<CalendarIcon className="mr-2 h-5 w-5" />
|
||||
Calendário
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-500">Dias em azul possuem agendamentos.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedCalendarDate}
|
||||
onSelect={setSelectedCalendarDate}
|
||||
initialFocus
|
||||
modifiers={{ booked: bookedDays }}
|
||||
modifiersClassNames={{
|
||||
booked: "bg-blue-600 text-white aria-selected:!bg-blue-700 hover:!bg-blue-700/90"
|
||||
}}
|
||||
className="rounded-md border p-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
const getStatusVariant = (status: LocalStorageAppointment['status']) => {
|
||||
// ... (código mantido)
|
||||
switch (status) {
|
||||
case "confirmada":
|
||||
case "agendada":
|
||||
return "default";
|
||||
case "realizada":
|
||||
return "secondary";
|
||||
case "cancelada":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (id: number) => {
|
||||
// ... (código mantido para cancelamento)
|
||||
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
|
||||
const allAppts: LocalStorageAppointment[] = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
|
||||
|
||||
const updatedAppointments = allAppts.map(app =>
|
||||
app.id === id ? { ...app, status: "cancelada" as const } : app
|
||||
);
|
||||
|
||||
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments));
|
||||
loadAppointments();
|
||||
toast.info(`Consulta cancelada com sucesso.`);
|
||||
};
|
||||
|
||||
const handleReSchedule = (id: number) => {
|
||||
toast.info(`Reagendamento da Consulta ID: ${id}. Navegar para a página de agendamento.`);
|
||||
};
|
||||
|
||||
const displayDate = selectedCalendarDate ?
|
||||
new Date(selectedCalendarDate).toLocaleDateString("pt-BR", { weekday: 'long', day: '2-digit', month: 'long' }) :
|
||||
"Selecione uma data";
|
||||
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Agenda Médica Centralizada</h1>
|
||||
<p className="text-gray-600">Todas as consultas do sistema são exibidas aqui ({LOGGED_IN_DOCTOR_NAME})</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Consultas para: {displayDate}</h2>
|
||||
<Button onClick={loadAppointments} disabled={isLoading} variant="outline" size="sm">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Atualizar Agenda
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* NOVO LAYOUT DE DUAS COLUNAS */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* COLUNA 1: CALENDÁRIO */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<CalendarIcon className="mr-2 h-5 w-5" />
|
||||
Calendário
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-500">Dias em azul possuem agendamentos.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedCalendarDate}
|
||||
onSelect={setSelectedCalendarDate}
|
||||
initialFocus
|
||||
// A CHAVE DO HIGHLIGHT: Passa o array de datas agendadas
|
||||
modifiers={{ booked: bookedDays }}
|
||||
// Define o estilo CSS para o modificador 'booked'
|
||||
modifiersClassNames={{
|
||||
booked: "bg-blue-600 text-white aria-selected:!bg-blue-700 hover:!bg-blue-700/90"
|
||||
}}
|
||||
className="rounded-md border p-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* COLUNA 2: LISTA DE CONSULTAS FILTRADAS */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-center text-lg text-gray-500">Carregando a agenda...</p>
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<p className="text-center text-lg text-gray-500">Nenhuma consulta encontrada para a data selecionada.</p>
|
||||
) : (
|
||||
filteredAppointments.map((appointment) => {
|
||||
const showActions = appointment.status === "agendada" || appointment.status === "confirmada";
|
||||
|
||||
return (
|
||||
<Card key={appointment.id} className="shadow-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-xl font-semibold flex items-center">
|
||||
<User className="mr-2 h-5 w-5 text-blue-600" />
|
||||
{appointment.patientName}
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(appointment.status)} className="uppercase">
|
||||
{appointment.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid md:grid-cols-3 gap-4 pt-4">
|
||||
{/* Detalhes e Ações... (mantidos) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<User className="mr-2 h-4 w-4 text-gray-500" />
|
||||
<span className="font-semibold">Médico:</span> {appointment.doctor}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<CalendarIcon className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{new Date(appointment.date).toLocaleDateString("pt-BR", { timeZone: "UTC" })}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<Clock className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<MapPin className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.location}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<Phone className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.phone || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center items-end">
|
||||
{showActions && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleReSchedule(appointment.id)}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reagendar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleCancel(appointment.id)}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-lg text-gray-500 p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-blue-600" />
|
||||
Carregando a agenda...
|
||||
</div>
|
||||
</DoctorLayout>
|
||||
);
|
||||
) : error ? (
|
||||
<div className="text-center text-lg text-red-600 p-8">{error}</div>
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<p className="text-center text-lg text-gray-500 p-8">Nenhuma consulta encontrada para a data selecionada.</p>
|
||||
) : (
|
||||
filteredAppointments.map((appointment) => {
|
||||
const showActions = appointment.status === "requested" || appointment.status === "confirmed";
|
||||
|
||||
return (
|
||||
<Card key={appointment.id} className="shadow-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-xl font-semibold flex items-center">
|
||||
<User className="mr-2 h-5 w-5 text-blue-600" />
|
||||
{appointment.patients?.full_name || 'Paciente não informado'}
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(appointment.status)} className="uppercase">
|
||||
{appointment.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid md:grid-cols-3 gap-4 pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<User className="mr-2 h-4 w-4 text-gray-500" />
|
||||
<span className="font-semibold">Médico:</span> {appointment.doctors?.full_name || 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<CalendarIcon className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{format(parseISO(appointment.scheduled_at), 'dd/MM/yyyy')}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<Clock className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{format(parseISO(appointment.scheduled_at), 'HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<MapPin className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.appointment_type || 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-700">
|
||||
<Phone className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.patients?.phone_mobile || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center items-end">
|
||||
{showActions && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleReSchedule(appointment.id)}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reagendar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleCancel(appointment.id)}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,41 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Esqueleto do Cabeçalho */}
|
||||
<div>
|
||||
<Skeleton className="h-8 w-1/4 mb-2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
|
||||
{/* Esqueleto dos Botões de Ação e Filtro */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-10 w-1/4" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
{/* Esqueleto da Tabela/Lista de Médicos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center space-x-4 p-2">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import DoctorLayout from "@/components/doctor-layout";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -10,38 +9,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Eye, Edit, Calendar, Trash2 } from "lucide-react";
|
||||
import { api } from "@/services/api.mjs";
|
||||
import { pacientesApi, Patient } from "@/services/pacientesApi";
|
||||
import { PatientDetailsModal } from "@/components/ui/patient-details-modal";
|
||||
|
||||
interface Paciente {
|
||||
id: string;
|
||||
nome: string;
|
||||
telefone: string;
|
||||
cidade: string;
|
||||
estado: string;
|
||||
ultimoAtendimento?: string;
|
||||
proximoAtendimento?: string;
|
||||
email?: string;
|
||||
birth_date?: string;
|
||||
cpf?: string;
|
||||
blood_type?: string;
|
||||
weight_kg?: number;
|
||||
height_m?: number;
|
||||
street?: string;
|
||||
number?: string;
|
||||
complement?: string;
|
||||
neighborhood?: string;
|
||||
cep?: string;
|
||||
}
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
export default function PacientesPage() {
|
||||
const [pacientes, setPacientes] = useState<Paciente[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pacientes, setPacientes] = useState<Patient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(10);
|
||||
|
||||
const handleOpenModal = (patient: Paciente) => {
|
||||
const handleOpenModal = (patient: Patient) => {
|
||||
setSelectedPatient(patient);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@ -51,149 +32,144 @@ export default function PacientesPage() {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return "";
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('pt-BR').format(date);
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('pt-BR').format(date);
|
||||
} catch {
|
||||
return "Data inválida";
|
||||
}
|
||||
};
|
||||
|
||||
const [itemsPerPage, setItemsPerPage] = useState(5);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const handleDelete = async (patientId: string) => {
|
||||
if (!confirm("Tem certeza que deseja excluir este paciente?")) return;
|
||||
|
||||
try {
|
||||
await pacientesApi.delete(patientId);
|
||||
setPacientes(prevPacientes => prevPacientes.filter(p => p.id !== patientId));
|
||||
toast({
|
||||
title: "Sucesso",
|
||||
description: "Paciente excluído com sucesso.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Erro",
|
||||
description: err.message || "Não foi possível excluir o paciente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPacientes = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await pacientesApi.list();
|
||||
setPacientes(data || []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Erro ao carregar pacientes");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPacientes();
|
||||
}, []);
|
||||
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
const currentItems = pacientes.slice(indexOfFirstItem, indexOfLastItem);
|
||||
const totalPages = Math.ceil(pacientes.length / itemsPerPage);
|
||||
|
||||
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPacientes() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const json = await api.get("/rest/v1/patients");
|
||||
const items = Array.isArray(json) ? json : (Array.isArray(json?.data) ? json.data : []);
|
||||
|
||||
const mapped = items.map((p: any) => ({
|
||||
id: String(p.id ?? ""),
|
||||
nome: p.full_name ?? "",
|
||||
telefone: p.phone_mobile ?? "",
|
||||
cidade: p.city ?? "",
|
||||
estado: p.state ?? "",
|
||||
ultimoAtendimento: formatDate(p.created_at) ?? "",
|
||||
proximoAtendimento: "",
|
||||
email: p.email ?? "",
|
||||
birth_date: p.birth_date ?? "",
|
||||
cpf: p.cpf ?? "",
|
||||
blood_type: p.blood_type ?? "",
|
||||
weight_kg: p.weight_kg ?? 0,
|
||||
height_m: p.height_m ?? 0,
|
||||
street: p.street ?? "",
|
||||
number: p.number ?? "",
|
||||
complement: p.complement ?? "",
|
||||
neighborhood: p.neighborhood ?? "",
|
||||
cep: p.cep ?? "",
|
||||
}));
|
||||
|
||||
setPacientes(mapped);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Erro ao carregar pacientes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchPacientes();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DoctorLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
|
||||
<p className="text-muted-foreground">Lista de pacientes vinculados</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
|
||||
<p className="text-muted-foreground">Lista de pacientes vinculados</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium text-foreground">Nome</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Telefone</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Cidade</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Estado</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Último atendimento</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Próximo atendimento</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium text-foreground">Nome</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Telefone</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Cidade</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Estado</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Último atendimento</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Próximo atendimento</th>
|
||||
<th className="text-left p-4 font-medium text-foreground">Ações</th>
|
||||
<td colSpan={7} className="p-6 text-center text-muted-foreground">
|
||||
Carregando pacientes...
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-6 text-muted-foreground">
|
||||
Carregando pacientes...
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-6 text-center text-red-600">{`Erro: ${error}`}</td>
|
||||
</tr>
|
||||
) : currentItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
Nenhum paciente encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentItems.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border hover:bg-accent">
|
||||
<td className="p-4">{p.full_name}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.phone_mobile}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.city}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.state}</td>
|
||||
<td className="p-4 text-muted-foreground">{formatDate(p.created_at)}</td>
|
||||
<td className="p-4 text-muted-foreground">N/A</td>
|
||||
<td className="p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-primary hover:underline">Ações</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenModal(p)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver detalhes
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/doctor/pacientes/${p.id}/laudos`}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Laudos
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => alert(`Agenda para paciente ID: ${p.id}`)}>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Ver agenda
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(p.id)}
|
||||
className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-6 text-red-600">{`Erro: ${error}`}</td>
|
||||
</tr>
|
||||
) : pacientes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
Nenhum paciente encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentItems.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border hover:bg-accent">
|
||||
<td className="p-4">{p.nome}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.telefone}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.cidade}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.estado}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.ultimoAtendimento}</td>
|
||||
<td className="p-4 text-muted-foreground">{p.proximoAtendimento}</td>
|
||||
<td className="p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-primary hover:underline">Ações</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenModal(p)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver detalhes
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/doctor/medicos/${p.id}/laudos`}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Laudos
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => alert(`Agenda para paciente ID: ${p.id}`)}>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Ver agenda
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const newPacientes = pacientes.filter((pac) => pac.id !== p.id)
|
||||
setPacientes(newPacientes)
|
||||
alert(`Paciente ID: ${p.id} excluído`)
|
||||
}}
|
||||
className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pacientes.length > itemsPerPage && (
|
||||
<div className="flex justify-center space-x-2 mt-4 p-4">
|
||||
{Array.from({ length: Math.ceil(pacientes.length / itemsPerPage) }, (_, i) => (
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => paginate(i + 1)}
|
||||
@ -203,13 +179,13 @@ export default function PacientesPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PatientDetailsModal
|
||||
patient={selectedPatient}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
</DoctorLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
app/finance/layout.tsx
Normal file
74
app/finance/layout.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
// Caminho: app/(finance)/layout.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Nossas importações centralizadas
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import DashboardLayout, { UserProfile } from "@/components/layout/DashboardLayout";
|
||||
import { dashboardConfig } from "@/config/dashboard.config";
|
||||
|
||||
interface FinanceLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FinanceLayout({ children }: FinanceLayoutProps) {
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthentication = async () => {
|
||||
try {
|
||||
// 1. Busca o usuário logado via API
|
||||
const userData = await usuariosApi.getCurrentUser();
|
||||
|
||||
// 2. Pega a configuração específica do "financeiro"
|
||||
// Nota: No nosso config, a chave é 'financier'
|
||||
const config = dashboardConfig.financier;
|
||||
if (!config) {
|
||||
throw new Error("Configuração para o perfil 'financier' não encontrada.");
|
||||
}
|
||||
|
||||
// 3. Formata os dados para o perfil
|
||||
setUserProfile(config.getUserProfile(userData));
|
||||
|
||||
} catch (error) {
|
||||
// 4. Se falhar, redireciona para o login
|
||||
console.error("Falha na autenticação para financeiro:", error);
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuthentication();
|
||||
}, [router]);
|
||||
|
||||
// Enquanto a verificação estiver em andamento, mostra uma tela de carregamento
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Verificando autenticação...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não tiver perfil (redirect em andamento), não renderiza nada para evitar erros
|
||||
if (!userProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pega os itens de menu da configuração
|
||||
const menuItems = dashboardConfig.financier.menuItems;
|
||||
|
||||
// Renderiza o layout genérico com as props corretas
|
||||
return (
|
||||
<DashboardLayout menuItems={menuItems} userProfile={userProfile}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Caminho: app/(finance)/login/page.tsx
|
||||
|
||||
import { LoginForm } from "@/components/LoginForm";
|
||||
import Link from "next/link"; // Adicionado para o link de "Voltar"
|
||||
|
||||
export default function FinanceLoginPage() {
|
||||
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
|
||||
// O ideal no futuro é deletar esta página e redirecionar os usuários.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Área Financeira</h1>
|
||||
<p className="text-muted-foreground mb-8">Acesse o sistema de faturamento</p>
|
||||
|
||||
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
|
||||
{/* Chamando o LoginForm unificado sem props desnecessárias */}
|
||||
<LoginForm>
|
||||
{/* Adicionamos um link de "Voltar" como filho (children) */}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/">
|
||||
<span className="font-semibold text-primary hover:underline cursor-pointer">
|
||||
Voltar à página inicial
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</LoginForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import ManagerLayout from "@/components/manager-layout";
|
||||
// Removida a importação de ManagerLayout, pois a página agora é envolvida pelo ManagerLayout pai (em app/manager/layout.tsx)
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, Plus, User } from "lucide-react";
|
||||
import { Calendar, Clock, Plus, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { usersService } from "services/usersApi.mjs";
|
||||
import { doctorsService } from "services/doctorsApi.mjs";
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import { perfisApi } from "@/services/perfisApi";
|
||||
import { medicosApi } from "@/services/medicosApi";
|
||||
|
||||
export default function ManagerDashboard() {
|
||||
// 🔹 Estados para usuários
|
||||
@ -18,13 +19,24 @@ export default function ManagerDashboard() {
|
||||
const [doctors, setDoctors] = useState<any[]>([]);
|
||||
const [loadingDoctors, setLoadingDoctors] = useState(true);
|
||||
|
||||
// REMOVIDO: mockUserProfile e mockMenuItems foram removidos.
|
||||
// Agora, o layout (ManagerLayout em app/manager/layout.tsx) é responsável por fornecer esses dados.
|
||||
|
||||
|
||||
// 🔹 Buscar primeiro usuário
|
||||
useEffect(() => {
|
||||
async function fetchFirstUser() {
|
||||
try {
|
||||
const data = await usersService.list_roles();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setFirstUser(data[0]);
|
||||
// Passo 1: Buscar a lista de papéis/usuários
|
||||
const rolesData = await usuariosApi.listRoles();
|
||||
if (Array.isArray(rolesData) && rolesData.length > 0) {
|
||||
const firstRole = rolesData[0];
|
||||
// Passo 2: Usar o user_id do primeiro papel para buscar o perfil completo
|
||||
// NOTE: Esta lógica parece buscar a lista de perfis, não um único perfil por user_id.
|
||||
// Mantendo a estrutura para evitar quebrar a lógica de dados, mas é uma área a ser revisada.
|
||||
const profileData = await perfisApi.list();
|
||||
// Se list() retorna um array, talvez você queira mapear para encontrar o perfil do firstRole.user_id, mas mantendo o original:
|
||||
setFirstUser(profileData[0]); // Assumindo que o primeiro item é o perfil
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar usuário:", error);
|
||||
@ -36,13 +48,13 @@ export default function ManagerDashboard() {
|
||||
fetchFirstUser();
|
||||
}, []);
|
||||
|
||||
// 🔹 Buscar 3 primeiros médicos
|
||||
// 🔹 Buscar 3 primeiros médicos (sem alterações aqui)
|
||||
useEffect(() => {
|
||||
async function fetchDoctors() {
|
||||
try {
|
||||
const data = await doctorsService.list(); // ajuste se seu service tiver outro método
|
||||
const data = await medicosApi.list();
|
||||
if (Array.isArray(data)) {
|
||||
setDoctors(data.slice(0, 3)); // pega os 3 primeiros
|
||||
setDoctors(data.slice(0, 3));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar médicos:", error);
|
||||
@ -55,136 +67,95 @@ export default function ManagerDashboard() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Cabeçalho */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
|
||||
</div>
|
||||
|
||||
{/* Cards principais */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Card 1 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Relatórios gerenciais</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">0</div>
|
||||
<p className="text-xs text-muted-foreground">Relatórios disponíveis</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2 — Gestão de usuários */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Gestão de usuários</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingUser ? (
|
||||
<div className="text-gray-500 text-sm">Carregando usuário...</div>
|
||||
) : firstUser ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{firstUser.full_name || "Sem nome"}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{firstUser.email || "Sem e-mail cadastrado"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Nenhum usuário encontrado</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3 — Perfil */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<p className="text-xs text-muted-foreground">Dados completos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cards secundários */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Card — Ações rápidas */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/manager/home">
|
||||
<Button className="w-full justify-start">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Gestão de Médicos
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/manager/usuario">
|
||||
<Button variant="outline" className="w-full justify-start bg-transparent">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Usuários Cadastrados
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/manager/home/novo">
|
||||
<Button variant="outline" className="w-full justify-start bg-transparent">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Adicionar Novo Médico
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/manager/usuario/novo">
|
||||
<Button variant="outline" className="w-full justify-start bg-transparent">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Criar novo Usuário
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card — Gestão de Médicos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de Médicos</CardTitle>
|
||||
<CardDescription>Médicos cadastrados recentemente</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDoctors ? (
|
||||
<p className="text-sm text-gray-500">Carregando médicos...</p>
|
||||
) : doctors.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Nenhum médico cadastrado.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{doctors.map((doc, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-100"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{doc.full_name || "Sem nome"}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{doc.specialty || "Sem especialidade"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-green-700">
|
||||
{doc.active ? "Ativo" : "Inativo"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
// REMOVIDO: A página agora apenas retorna seu conteúdo, confiando no ManagerLayout em app/manager/layout.tsx para o wrapper.
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Relatórios gerenciais</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">0</div>
|
||||
<p className="text-xs text-muted-foreground">Relatórios disponíveis</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Gestão de usuários</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingUser ? (
|
||||
<div className="text-gray-500 text-sm">Carregando usuário...</div>
|
||||
) : firstUser ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{firstUser.full_name || "Sem nome"}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{firstUser.email || "Sem e-mail cadastrado"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Nenhum usuário encontrado</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<p className="text-xs text-muted-foreground">Dados completos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/manager/home"><Button className="w-full justify-start"><User className="mr-2 h-4 w-4" />Gestão de Médicos</Button></Link>
|
||||
<Link href="/manager/usuario"><Button variant="outline" className="w-full justify-start bg-transparent"><User className="mr-2 h-4 w-4" />Usuários Cadastrados</Button></Link>
|
||||
<Link href="/manager/home/novo"><Button variant="outline" className="w-full justify-start bg-transparent"><Plus className="mr-2 h-4 w-4" />Adicionar Novo Médico</Button></Link>
|
||||
<Link href="/manager/usuario/novo"><Button variant="outline" className="w-full justify-start bg-transparent"><Plus className="mr-2 h-4 w-4" />Criar novo Usuário</Button></Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de Médicos</CardTitle>
|
||||
<CardDescription>Médicos cadastrados recentemente</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDoctors ? (
|
||||
<p className="text-sm text-gray-500">Carregando médicos...</p>
|
||||
) : doctors.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Nenhum médico cadastrado.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{doctors.map((doc, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-100">
|
||||
<div>
|
||||
<p className="font-medium">{doc.full_name || "Sem nome"}</p>
|
||||
<p className="text-sm text-gray-600">{doc.specialty || "Sem especialidade"}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-green-700">{doc.active ? "Ativo" : "Inativo"}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,492 +1,83 @@
|
||||
"use client"
|
||||
// app/manager/home/[id]/editar/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Save, Loader2, ArrowLeft } from "lucide-react"
|
||||
import ManagerLayout from "@/components/manager-layout"
|
||||
import { doctorsService } from "services/doctorsApi.mjs";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { pacientesApi } from "@/services/pacientesApi";
|
||||
|
||||
const UF_LIST = ["AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO"];
|
||||
|
||||
interface DoctorFormData {
|
||||
nomeCompleto: string;
|
||||
crm: string;
|
||||
crmEstado: string;
|
||||
especialidade: string;
|
||||
cpf: string;
|
||||
email: string;
|
||||
dataNascimento: string;
|
||||
rg: string;
|
||||
telefoneCelular: string;
|
||||
telefone2: string;
|
||||
cep: string;
|
||||
endereco: string;
|
||||
numero: string;
|
||||
complemento: string;
|
||||
bairro: string;
|
||||
cidade: string;
|
||||
estado: string;
|
||||
ativo: boolean;
|
||||
observacoes: string;
|
||||
interface Patient {
|
||||
id?: number | string;
|
||||
full_name?: string;
|
||||
[k: string]: any;
|
||||
}
|
||||
const apiMap: { [K in keyof DoctorFormData]: string | null } = {
|
||||
nomeCompleto: 'full_name', crm: 'crm', crmEstado: 'crm_uf', especialidade: 'specialty',
|
||||
cpf: 'cpf', email: 'email', dataNascimento: 'birth_date', rg: 'rg',
|
||||
telefoneCelular: 'phone_mobile', telefone2: 'phone2', cep: 'cep',
|
||||
endereco: 'street', numero: 'number', complemento: 'complement',
|
||||
bairro: 'neighborhood', cidade: 'city', estado: 'state', ativo: 'active',
|
||||
observacoes: null,
|
||||
};
|
||||
|
||||
const defaultFormData: DoctorFormData = {
|
||||
nomeCompleto: '', crm: '', crmEstado: '', especialidade: '', cpf: '', email: '',
|
||||
dataNascimento: '', rg: '', telefoneCelular: '', telefone2: '', cep: '',
|
||||
endereco: '', numero: '', complemento: '', bairro: '', cidade: '', estado: '',
|
||||
ativo: true, observacoes: '',
|
||||
};
|
||||
|
||||
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
|
||||
|
||||
const formatCPF = (value: string): string => {
|
||||
const cleaned = cleanNumber(value).substring(0, 11);
|
||||
return cleaned.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||
};
|
||||
|
||||
const formatCEP = (value: string): string => {
|
||||
const cleaned = cleanNumber(value).substring(0, 8);
|
||||
return cleaned.replace(/(\d{5})(\d{3})/, '$1-$2');
|
||||
};
|
||||
|
||||
const formatPhoneMobile = (value: string): string => {
|
||||
const cleaned = cleanNumber(value).substring(0, 11);
|
||||
if (cleaned.length > 10) {
|
||||
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
|
||||
}
|
||||
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
|
||||
};
|
||||
|
||||
export default function EditarMedicoPage() {
|
||||
const router = useRouter();
|
||||
export default function ManagerHomeEditPage() {
|
||||
const params = useParams();
|
||||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
const [formData, setFormData] = useState<DoctorFormData>(defaultFormData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const id = params?.id;
|
||||
const router = useRouter();
|
||||
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const apiToFormMap: { [key: string]: keyof DoctorFormData } = {
|
||||
'full_name': 'nomeCompleto', 'crm': 'crm', 'crm_uf': 'crmEstado', 'specialty': 'especialidade',
|
||||
'cpf': 'cpf', 'email': 'email', 'birth_date': 'dataNascimento', 'rg': 'rg',
|
||||
'phone_mobile': 'telefoneCelular', 'phone2': 'telefone2', 'cep': 'cep',
|
||||
'street': 'endereco', 'number': 'numero', 'complement': 'complemento',
|
||||
'neighborhood': 'bairro', 'city': 'cidade', 'state': 'estado', 'active': 'ativo'
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDoctor = async () => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await doctorsService.getById(id);
|
||||
|
||||
if (!data) {
|
||||
setError("Médico não encontrado.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialData: Partial<DoctorFormData> = {};
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
const formKey = apiToFormMap[key];
|
||||
if (formKey) {
|
||||
let value = data[key] === null ? '' : data[key];
|
||||
if (formKey === 'ativo') {
|
||||
value = !!value;
|
||||
} else if (typeof value !== 'boolean') {
|
||||
value = String(value);
|
||||
}
|
||||
initialData[formKey] = value as any;
|
||||
}
|
||||
});
|
||||
initialData.observacoes = "Observação carregada do sistema (exemplo de campo interno)";
|
||||
|
||||
setFormData(prev => ({ ...prev, ...initialData }));
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar dados:", e);
|
||||
setError("Não foi possível carregar os dados do médico.");
|
||||
if (!id) throw new Error("ID ausente");
|
||||
const data = await pacientesApi.getById(String(id));
|
||||
if (mounted) setPatient(data ?? null);
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao buscar paciente:", err);
|
||||
if (mounted) setError(err?.message ?? "Erro ao buscar paciente");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDoctor();
|
||||
}, [id]);
|
||||
|
||||
const handleInputChange = (key: keyof DoctorFormData, value: string | boolean) => {
|
||||
|
||||
|
||||
if (typeof value === 'string') {
|
||||
let maskedValue = value;
|
||||
if (key === 'cpf') maskedValue = formatCPF(value);
|
||||
if (key === 'cep') maskedValue = formatCEP(value);
|
||||
if (key === 'telefoneCelular' || key === 'telefone2') maskedValue = formatPhoneMobile(value);
|
||||
|
||||
setFormData((prev) => ({ ...prev, [key]: maskedValue }));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!id || !patient) return;
|
||||
setIsSaving(true);
|
||||
|
||||
if (!id) {
|
||||
setError("ID do médico ausente.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPayload: { [key: string]: any } = {};
|
||||
const formKeys = Object.keys(formData) as Array<keyof DoctorFormData>;
|
||||
|
||||
|
||||
formKeys.forEach((key) => {
|
||||
const apiFieldName = apiMap[key];
|
||||
|
||||
if (!apiFieldName) return;
|
||||
|
||||
let value = formData[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
let trimmedValue = value.trim();
|
||||
if (trimmedValue === '') {
|
||||
finalPayload[apiFieldName] = null;
|
||||
return;
|
||||
}
|
||||
if (key === 'crmEstado' || key === 'estado') {
|
||||
trimmedValue = trimmedValue.toUpperCase();
|
||||
}
|
||||
|
||||
value = trimmedValue;
|
||||
}
|
||||
|
||||
finalPayload[apiFieldName] = value;
|
||||
});
|
||||
|
||||
delete finalPayload.user_id;
|
||||
setError(null);
|
||||
try {
|
||||
await doctorsService.update(id, finalPayload);
|
||||
router.push("/manager/home");
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao salvar o médico:", e);
|
||||
let detailedError = "Erro ao atualizar. Verifique os dados e tente novamente.";
|
||||
|
||||
if (e.message && e.message.includes("duplicate key value violates unique constraint")) {
|
||||
detailedError = "O CPF ou CRM informado já está cadastrado em outro registro.";
|
||||
} else if (e.message && e.message.includes("Detalhes:")) {
|
||||
detailedError = e.message.split("Detalhes:")[1].trim();
|
||||
} else if (e.message) {
|
||||
detailedError = e.message;
|
||||
}
|
||||
|
||||
setError(`Erro ao atualizar. Detalhes: ${detailedError}`);
|
||||
await pacientesApi.update(String(id), patient);
|
||||
router.push("/manager/home");
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao salvar paciente:", err);
|
||||
setError(err?.message ?? "Erro ao salvar");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="flex justify-center items-center h-full w-full py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-600" />
|
||||
<p className="ml-2 text-gray-600">Carregando dados do médico...</p>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8">Carregando...</div>;
|
||||
if (error) return <div className="p-8 text-destructive">Erro: {error}</div>;
|
||||
if (!patient) return <div className="p-8">Paciente não encontrado.</div>;
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="w-full space-y-6 p-4 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Editar Médico: <span className="text-green-600">{formData.nomeCompleto}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Atualize as informações do médico (ID: {id}).
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/manager/home">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</Link>
|
||||
<main className="w-full p-4 md:p-8">
|
||||
<div className="max-w-screen-md mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4">Editar Paciente</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 border rounded">
|
||||
<div>
|
||||
<label className="block text-sm">Nome</label>
|
||||
<input value={patient.full_name ?? ""} onChange={(e) => setPatient({ ...patient, full_name: e.target.value })} required className="w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => router.push("/manager/home")} className="mr-2">Cancelar</button>
|
||||
<button type="submit" disabled={isSaving}>{isSaving ? "Salvando..." : "Salvar"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
|
||||
<p className="font-medium">Erro na Atualização:</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Dados Principais e Pessoais
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome Completo (full_name)</Label>
|
||||
<Input
|
||||
id="nomeCompleto"
|
||||
value={formData.nomeCompleto}
|
||||
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
|
||||
placeholder="Nome do Médico"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crm">CRM</Label>
|
||||
<Input
|
||||
id="crm"
|
||||
value={formData.crm}
|
||||
onChange={(e) => handleInputChange("crm", e.target.value)}
|
||||
placeholder="Ex: 123456"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crmEstado">UF do CRM (crm_uf)</Label>
|
||||
<Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}>
|
||||
<SelectTrigger id="crmEstado">
|
||||
<SelectValue placeholder="UF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UF_LIST.map(uf => (
|
||||
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="especialidade">Especialidade (specialty)</Label>
|
||||
<Input
|
||||
id="especialidade"
|
||||
value={formData.especialidade}
|
||||
onChange={(e) => handleInputChange("especialidade", e.target.value)}
|
||||
placeholder="Ex: Cardiologia"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF</Label>
|
||||
<Input
|
||||
id="cpf"
|
||||
value={formData.cpf}
|
||||
onChange={(e) => handleInputChange("cpf", e.target.value)}
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rg">RG</Label>
|
||||
<Input
|
||||
id="rg"
|
||||
value={formData.rg}
|
||||
onChange={(e) => handleInputChange("rg", e.target.value)}
|
||||
placeholder="00.000.000-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
placeholder="exemplo@dominio.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="dataNascimento">Data de Nascimento (birth_date)</Label>
|
||||
<Input
|
||||
id="dataNascimento"
|
||||
type="date"
|
||||
value={formData.dataNascimento}
|
||||
onChange={(e) => handleInputChange("dataNascimento", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-end justify-center pb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ativo"
|
||||
checked={formData.ativo}
|
||||
onCheckedChange={(checked) => handleInputChange("ativo", checked === true)}
|
||||
/>
|
||||
<Label htmlFor="ativo">Médico Ativo (active)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Contato e Endereço
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefoneCelular">Telefone Celular (phone_mobile)</Label>
|
||||
<Input
|
||||
id="telefoneCelular"
|
||||
value={formData.telefoneCelular}
|
||||
onChange={(e) => handleInputChange("telefoneCelular", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone2">Telefone Adicional (phone2)</Label>
|
||||
<Input
|
||||
id="telefone2"
|
||||
value={formData.telefone2}
|
||||
onChange={(e) => handleInputChange("telefone2", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input
|
||||
id="cep"
|
||||
value={formData.cep}
|
||||
onChange={(e) => handleInputChange("cep", e.target.value)}
|
||||
placeholder="00000-000"
|
||||
maxLength={9}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="endereco">Logradouro (street)</Label>
|
||||
<Input
|
||||
id="endereco"
|
||||
value={formData.endereco}
|
||||
onChange={(e) => handleInputChange("endereco", e.target.value)}
|
||||
placeholder="Rua, Avenida, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="numero">Número</Label>
|
||||
<Input
|
||||
id="numero"
|
||||
value={formData.numero}
|
||||
onChange={(e) => handleInputChange("numero", e.target.value)}
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input
|
||||
id="complemento"
|
||||
value={formData.complemento}
|
||||
onChange={(e) => handleInputChange("complemento", e.target.value)}
|
||||
placeholder="Apto, Bloco, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input
|
||||
id="bairro"
|
||||
value={formData.bairro}
|
||||
onChange={(e) => handleInputChange("bairro", e.target.value)}
|
||||
placeholder="Bairro"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input
|
||||
id="cidade"
|
||||
value={formData.cidade}
|
||||
onChange={(e) => handleInputChange("cidade", e.target.value)}
|
||||
placeholder="São Paulo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="estado">Estado (state)</Label>
|
||||
<Input
|
||||
id="estado"
|
||||
value={formData.estado}
|
||||
onChange={(e) => handleInputChange("estado", e.target.value)}
|
||||
placeholder="SP"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Observações (Apenas internas)
|
||||
</h2>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange("observacoes", e.target.value)}
|
||||
placeholder="Notas internas sobre o médico..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-4 pb-8 pt-4">
|
||||
<Link href="/manager/home">
|
||||
<Button type="button" variant="outline" disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isSaving ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,18 +9,18 @@ import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Upload, X, ChevronDown, Save, Loader2 } from "lucide-react"
|
||||
import { Upload, X, ChevronDown, Save, Loader2, Home, Users, Settings, LucideIcon } from "lucide-react"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import ManagerLayout from "@/components/manager-layout"
|
||||
import { doctorsService } from "services/doctorsApi.mjs";
|
||||
// IMPORTANTE: Se o ManagerLayout for responsável por renderizar o cabeçalho com a barra de pesquisa,
|
||||
// você precisará garantir que ele seja flexível para desativá-la ou não passá-la.
|
||||
import ManagerLayout from "@/components/layout/DashboardLayout"
|
||||
import { medicosApi } from "services/medicosApi";
|
||||
|
||||
|
||||
const UF_LIST = ["AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO"];
|
||||
|
||||
|
||||
|
||||
interface DoctorFormData {
|
||||
|
||||
nomeCompleto: string;
|
||||
crm: string;
|
||||
crmEstado: string;
|
||||
@ -79,6 +79,45 @@ const defaultFormData: DoctorFormData = {
|
||||
};
|
||||
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Tipos e dados necessários para o ManagerLayout (DashboardLayout)
|
||||
// ----------------------------------------------------------------------
|
||||
interface LayoutMenuItem {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LayoutUserProfile {
|
||||
name: string;
|
||||
secondaryText: string;
|
||||
avatarFallback: string;
|
||||
}
|
||||
|
||||
const MANAGER_MENU_ITEMS: LayoutMenuItem[] = [
|
||||
{
|
||||
href: "/manager/home",
|
||||
icon: Home,
|
||||
label: "Início",
|
||||
},
|
||||
{
|
||||
href: "/manager/medicos",
|
||||
icon: Users,
|
||||
label: "Médicos",
|
||||
},
|
||||
{
|
||||
href: "/manager/configuracoes",
|
||||
icon: Settings,
|
||||
label: "Configurações",
|
||||
},
|
||||
];
|
||||
|
||||
const MANAGER_USER_PROFILE: LayoutUserProfile = {
|
||||
name: "Gerente (Placeholder)",
|
||||
secondaryText: "gerente.placeholder@mediconnect.com",
|
||||
avatarFallback: "GP",
|
||||
};
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
||||
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
|
||||
@ -102,7 +141,9 @@ const formatPhoneMobile = (value: string): string => {
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// COMPONENTE PRINCIPAL
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function NovoMedicoPage() {
|
||||
const router = useRouter();
|
||||
@ -201,7 +242,7 @@ export default function NovoMedicoPage() {
|
||||
|
||||
try {
|
||||
|
||||
const response = await doctorsService.create(finalPayload);
|
||||
const response = await medicosApi.create(finalPayload);
|
||||
router.push("/manager/home");
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao salvar o médico:", e);
|
||||
@ -210,10 +251,10 @@ export default function NovoMedicoPage() {
|
||||
|
||||
|
||||
if (e.message && e.message.includes("duplicate key value violates unique constraint")) {
|
||||
|
||||
|
||||
detailedError = "O CPF ou CRM informado já está cadastrado no sistema. Por favor, verifique os dados de identificação.";
|
||||
} else if (e.message && e.message.includes("Detalhes:")) {
|
||||
|
||||
|
||||
detailedError = e.message.split("Detalhes:")[1].trim();
|
||||
} else if (e.message) {
|
||||
detailedError = e.message;
|
||||
@ -226,309 +267,320 @@ export default function NovoMedicoPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="w-full space-y-6 p-4 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Novo Médico</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Preencha os dados do novo médico para cadastro.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/manager/home">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<ManagerLayout
|
||||
menuItems={MANAGER_MENU_ITEMS}
|
||||
userProfile={MANAGER_USER_PROFILE}
|
||||
// ADICIONADO: Prop para indicar que esta página não deve ter barra de pesquisa
|
||||
// VOCÊ DEVE GARANTIR QUE ManagerLayout USE ESTA PROP PARA OCULTAR A BARRA DE PESQUISA
|
||||
hideSearch={true}
|
||||
>
|
||||
{/* GARANTINDO W-FULL: O contêiner principal ocupa 100% da largura. */}
|
||||
<div className="w-full space-y-6 p-4 md:p-8 bg-white min-h-full">
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
|
||||
<p className="font-medium">Erro no Cadastro:</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Dados Principais e Pessoais
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome Completo *</Label>
|
||||
<Input
|
||||
id="nomeCompleto"
|
||||
value={formData.nomeCompleto}
|
||||
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
|
||||
placeholder="Nome do Médico"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crm">CRM *</Label>
|
||||
<Input
|
||||
id="crm"
|
||||
value={formData.crm}
|
||||
onChange={(e) => handleInputChange("crm", e.target.value)}
|
||||
placeholder="Ex: 123456"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crmEstado">UF do CRM *</Label>
|
||||
<Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}>
|
||||
<SelectTrigger id="crmEstado">
|
||||
<SelectValue placeholder="UF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UF_LIST.map(uf => (
|
||||
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="especialidade">Especialidade</Label>
|
||||
<Input
|
||||
id="especialidade"
|
||||
value={formData.especialidade}
|
||||
onChange={(e) => handleInputChange("especialidade", e.target.value)}
|
||||
placeholder="Ex: Cardiologia"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF *</Label>
|
||||
<Input
|
||||
id="cpf"
|
||||
value={formData.cpf}
|
||||
onChange={(e) => handleInputChange("cpf", e.target.value)}
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rg">RG</Label>
|
||||
<Input
|
||||
id="rg"
|
||||
value={formData.rg}
|
||||
onChange={(e) => handleInputChange("rg", e.target.value)}
|
||||
placeholder="00.000.000-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="email">E-mail *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
placeholder="exemplo@dominio.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="dataNascimento">Data de Nascimento</Label>
|
||||
<Input
|
||||
id="dataNascimento"
|
||||
type="date"
|
||||
value={formData.dataNascimento}
|
||||
onChange={(e) => handleInputChange("dataNascimento", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Novo Médico</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Preencha os dados do novo médico para cadastro.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/manager/home">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Contato e Endereço
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefoneCelular">Telefone Celular</Label>
|
||||
<Input
|
||||
id="telefoneCelular"
|
||||
value={formData.telefoneCelular}
|
||||
onChange={(e) => handleInputChange("telefoneCelular", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
|
||||
<p className="font-medium">Erro no Cadastro:</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Dados Principais e Pessoais
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome Completo *</Label>
|
||||
<Input
|
||||
id="nomeCompleto"
|
||||
value={formData.nomeCompleto}
|
||||
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
|
||||
placeholder="Nome do Médico"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crm">CRM *</Label>
|
||||
<Input
|
||||
id="crm"
|
||||
value={formData.crm}
|
||||
onChange={(e) => handleInputChange("crm", e.target.value)}
|
||||
placeholder="Ex: 123456"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="crmEstado">UF do CRM *</Label>
|
||||
<Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}>
|
||||
<SelectTrigger id="crmEstado">
|
||||
<SelectValue placeholder="UF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UF_LIST.map(uf => (
|
||||
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone2">Telefone Adicional</Label>
|
||||
<Input
|
||||
id="telefone2"
|
||||
value={formData.telefone2}
|
||||
onChange={(e) => handleInputChange("telefone2", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="especialidade">Especialidade</Label>
|
||||
<Input
|
||||
id="especialidade"
|
||||
value={formData.especialidade}
|
||||
onChange={(e) => handleInputChange("especialidade", e.target.value)}
|
||||
placeholder="Ex: Cardiologia"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF *</Label>
|
||||
<Input
|
||||
id="cpf"
|
||||
value={formData.cpf}
|
||||
onChange={(e) => handleInputChange("cpf", e.target.value)}
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rg">RG</Label>
|
||||
<Input
|
||||
id="rg"
|
||||
value={formData.rg}
|
||||
onChange={(e) => handleInputChange("rg", e.target.value)}
|
||||
placeholder="00.000.000-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-end justify-center pb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ativo"
|
||||
checked={formData.ativo}
|
||||
onCheckedChange={(checked) => handleInputChange("ativo", checked === true)}
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="email">E-mail *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
placeholder="exemplo@dominio.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="dataNascimento">Data de Nascimento</Label>
|
||||
<Input
|
||||
id="dataNascimento"
|
||||
type="date"
|
||||
value={formData.dataNascimento}
|
||||
onChange={(e) => handleInputChange("dataNascimento", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Contato e Endereço
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefoneCelular">Telefone Celular</Label>
|
||||
<Input
|
||||
id="telefoneCelular"
|
||||
value={formData.telefoneCelular}
|
||||
onChange={(e) => handleInputChange("telefoneCelular", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone2">Telefone Adicional</Label>
|
||||
<Input
|
||||
id="telefone2"
|
||||
value={formData.telefone2}
|
||||
onChange={(e) => handleInputChange("telefone2", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-end justify-center pb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ativo"
|
||||
checked={formData.ativo}
|
||||
onCheckedChange={(checked) => handleInputChange("ativo", checked === true)}
|
||||
/>
|
||||
<Label htmlFor="ativo">Médico Ativo</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input
|
||||
id="cep"
|
||||
value={formData.cep}
|
||||
onChange={(e) => handleInputChange("cep", e.target.value)}
|
||||
placeholder="00000-000"
|
||||
maxLength={9}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="endereco">Rua</Label>
|
||||
<Input
|
||||
id="endereco"
|
||||
value={formData.endereco}
|
||||
onChange={(e) => handleInputChange("endereco", e.target.value)}
|
||||
placeholder="Rua, Avenida, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="numero">Número</Label>
|
||||
<Input
|
||||
id="numero"
|
||||
value={formData.numero}
|
||||
onChange={(e) => handleInputChange("numero", e.target.value)}
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input
|
||||
id="complemento"
|
||||
value={formData.complemento}
|
||||
onChange={(e) => handleInputChange("complemento", e.target.value)}
|
||||
placeholder="Apto, Bloco, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input
|
||||
id="bairro"
|
||||
value={formData.bairro}
|
||||
onChange={(e) => handleInputChange("bairro", e.target.value)}
|
||||
placeholder="Bairro"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="estado">Estado</Label>
|
||||
<Input
|
||||
id="estado"
|
||||
value={formData.estado}
|
||||
onChange={(e) => handleInputChange("estado", e.target.value)}
|
||||
placeholder="SP"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input
|
||||
id="cidade"
|
||||
value={formData.cidade}
|
||||
onChange={(e) => handleInputChange("cidade", e.target.value)}
|
||||
placeholder="São Paulo"
|
||||
/>
|
||||
<Label htmlFor="ativo">Médico Ativo</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input
|
||||
id="cep"
|
||||
value={formData.cep}
|
||||
onChange={(e) => handleInputChange("cep", e.target.value)}
|
||||
placeholder="00000-000"
|
||||
maxLength={9}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="endereco">Rua</Label>
|
||||
<Input
|
||||
id="endereco"
|
||||
value={formData.endereco}
|
||||
onChange={(e) => handleInputChange("endereco", e.target.value)}
|
||||
placeholder="Rua, Avenida, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="numero">Número</Label>
|
||||
<Input
|
||||
id="numero"
|
||||
value={formData.numero}
|
||||
onChange={(e) => handleInputChange("numero", e.target.value)}
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-3">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input
|
||||
id="complemento"
|
||||
value={formData.complemento}
|
||||
onChange={(e) => handleInputChange("complemento", e.target.value)}
|
||||
placeholder="Apto, Bloco, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input
|
||||
id="bairro"
|
||||
value={formData.bairro}
|
||||
onChange={(e) => handleInputChange("bairro", e.target.value)}
|
||||
placeholder="Bairro"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="estado">Estado</Label>
|
||||
<Input
|
||||
id="estado"
|
||||
value={formData.estado}
|
||||
onChange={(e) => handleInputChange("estado", e.target.value)}
|
||||
placeholder="SP"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-1">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input
|
||||
id="cidade"
|
||||
value={formData.cidade}
|
||||
onChange={(e) => handleInputChange("cidade", e.target.value)}
|
||||
placeholder="São Paulo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Outras Informações (Internas)
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="observacoes">Observações (Apenas internas)</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange("observacoes", e.target.value)}
|
||||
placeholder="Notas internas sobre o médico..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Collapsible open={anexosOpen} onOpenChange={setAnexosOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex justify-between items-center cursor-pointer pb-2 border-b">
|
||||
<h2 className="text-md font-semibold text-gray-800">Anexos ({formData.anexos.length})</h2>
|
||||
<ChevronDown className={`w-5 h-5 transition-transform ${anexosOpen ? 'rotate-180' : 'rotate-0'}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<Button type="button" onClick={adicionarAnexo} variant="outline" className="w-full">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Adicionar Documento
|
||||
</Button>
|
||||
{formData.anexos.map((anexo) => (
|
||||
<div key={anexo.id} className="flex items-center justify-between p-3 bg-gray-50 border rounded-lg">
|
||||
<span className="text-sm text-gray-700">{anexo.name}</span>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removerAnexo(anexo.id)}>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
|
||||
Outras Informações (Internas)
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="observacoes">Observações (Apenas internas)</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange("observacoes", e.target.value)}
|
||||
placeholder="Notas internas sobre o médico..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Collapsible open={anexosOpen} onOpenChange={setAnexosOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex justify-between items-center cursor-pointer pb-2 border-b">
|
||||
<h2 className="text-md font-semibold text-gray-800">Anexos ({formData.anexos.length})</h2>
|
||||
<ChevronDown className={`w-5 h-5 transition-transform ${anexosOpen ? 'rotate-180' : 'rotate-0'}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<Button type="button" onClick={adicionarAnexo} variant="outline" className="w-full">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Adicionar Documento
|
||||
</Button>
|
||||
{formData.anexos.map((anexo) => (
|
||||
<div key={anexo.id} className="flex items-center justify-between p-3 bg-gray-50 border rounded-lg">
|
||||
<span className="text-sm text-gray-700">{anexo.name}</span>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removerAnexo(anexo.id)}>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-4 pb-8 pt-4">
|
||||
<Link href="/manager/home">
|
||||
<Button type="button" variant="outline" disabled={isSaving}>
|
||||
Cancelar
|
||||
<div className="flex justify-end gap-4 pb-8 pt-4">
|
||||
<Link href="/manager/home">
|
||||
<Button type="button" variant="outline" disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isSaving ? "Salvando..." : "Salvar Médico"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isSaving ? "Salvando..." : "Salvar Médico"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import ManagerLayout from "@/components/manager-layout";
|
||||
import Link from "next/link"
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Edit, Trash2, Eye, Calendar, Filter, MoreVertical, Loader2 } from "lucide-react"
|
||||
import { medicosApi, Doctor } from "@/services/medicosApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Edit, Trash2, Eye, Calendar, Filter, MoreVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -17,36 +17,20 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import { doctorsService } from "services/doctorsApi.mjs";
|
||||
|
||||
|
||||
interface Doctor {
|
||||
id: number;
|
||||
full_name: string;
|
||||
specialty: string;
|
||||
crm: string;
|
||||
phone_mobile: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
|
||||
}
|
||||
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface DoctorDetails {
|
||||
nome: string;
|
||||
crm: string;
|
||||
especialidade: string;
|
||||
|
||||
contato: {
|
||||
celular?: string;
|
||||
telefone1?: string;
|
||||
}
|
||||
};
|
||||
endereco: {
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
}
|
||||
};
|
||||
convenio?: string;
|
||||
vip?: boolean;
|
||||
status?: string;
|
||||
@ -58,100 +42,80 @@ interface DoctorDetails {
|
||||
export default function DoctorsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
|
||||
const [doctorDetails, setDoctorDetails] = useState<DoctorDetails | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [doctorToDeleteId, setDoctorToDeleteId] = useState<number | null>(null);
|
||||
|
||||
|
||||
const [doctorToDeleteId, setDoctorToDeleteId] = useState<string | null>(null);
|
||||
|
||||
|
||||
const fetchDoctors = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
const data: Doctor[] = await doctorsService.list();
|
||||
setDoctors(data || []);
|
||||
const data: Doctor[] = await medicosApi.list();
|
||||
setDoctors(data || []);
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao carregar lista de médicos:", e);
|
||||
setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API.");
|
||||
setDoctors([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchDoctors();
|
||||
}, [fetchDoctors]);
|
||||
|
||||
|
||||
const openDetailsDialog = async (doctor: Doctor) => {
|
||||
setDetailsDialogOpen(true);
|
||||
|
||||
setDoctorDetails({
|
||||
nome: doctor.full_name,
|
||||
crm: doctor.crm,
|
||||
especialidade: doctor.specialty,
|
||||
contato: {
|
||||
celular: doctor.phone_mobile ?? undefined,
|
||||
telefone1: undefined
|
||||
},
|
||||
endereco: {
|
||||
cidade: doctor.city ?? undefined,
|
||||
estado: doctor.state ?? undefined,
|
||||
},
|
||||
|
||||
convenio: "Particular",
|
||||
vip: false,
|
||||
status: "Ativo",
|
||||
ultimo_atendimento: "N/A",
|
||||
proximo_atendimento: "N/A",
|
||||
nome: doctor.full_name,
|
||||
crm: doctor.crm,
|
||||
especialidade: doctor.specialty,
|
||||
contato: {
|
||||
celular: doctor.phone_mobile ?? undefined,
|
||||
telefone1: undefined,
|
||||
},
|
||||
endereco: {
|
||||
cidade: doctor.city ?? undefined,
|
||||
estado: doctor.state ?? undefined,
|
||||
},
|
||||
convenio: "Particular",
|
||||
vip: false,
|
||||
status: "Ativo",
|
||||
ultimo_atendimento: "N/A",
|
||||
proximo_atendimento: "N/A",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (doctorToDeleteId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await doctorsService.delete(doctorToDeleteId);
|
||||
|
||||
console.log(`Médico com ID ${doctorToDeleteId} excluído com sucesso!`);
|
||||
|
||||
await medicosApi.delete(doctorToDeleteId);
|
||||
setDeleteDialogOpen(false);
|
||||
setDoctorToDeleteId(null);
|
||||
await fetchDoctors();
|
||||
await fetchDoctors();
|
||||
} catch (e) {
|
||||
console.error("Erro ao excluir:", e);
|
||||
|
||||
alert("Erro ao excluir médico.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError("Erro ao excluir o médico. Tente novamente.");
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (doctorId: number) => {
|
||||
const openDeleteDialog = (doctorId: string) => {
|
||||
setDoctorToDeleteId(doctorId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleEdit = (doctorId: number) => {
|
||||
|
||||
const handleEdit = (doctorId: string) => {
|
||||
router.push(`/manager/home/${doctorId}/editar`);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@ -166,7 +130,6 @@ export default function DoctorsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<Filter className="w-5 h-5 text-gray-400" />
|
||||
<Select>
|
||||
@ -191,21 +154,18 @@ export default function DoctorsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
|
||||
Carregando médicos...
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
|
||||
Carregando médicos...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
<div className="p-8 text-center text-red-600">{error}</div>
|
||||
) : doctors.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.
|
||||
</div>
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
@ -227,39 +187,33 @@ export default function DoctorsPage() {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{doctor.specialty}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{doctor.phone_mobile || "N/A"}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{(doctor.city || doctor.state) ? `${doctor.city || ''}${doctor.city && doctor.state ? '/' : ''}${doctor.state || ''}` : "N/A"}
|
||||
{(doctor.city || doctor.state) ? `${doctor.city || ""}${doctor.city && doctor.state ? "/" : ""}${doctor.state || ""}` : "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
|
||||
<div className="flex justify-end space-x-1">
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => openDetailsDialog(doctor)} title="Visualizar Detalhes">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => handleEdit(doctor.id)} title="Editar">
|
||||
<Edit className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => openDeleteDialog(doctor.id)} title="Excluir">
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" title="Mais Ações">
|
||||
<span className="sr-only">Mais Ações</span>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Agendar Consulta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="icon" onClick={() => openDetailsDialog(doctor)} title="Visualizar Detalhes">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => handleEdit(doctor.id)} title="Editar">
|
||||
<Edit className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => openDeleteDialog(doctor.id)} title="Excluir">
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" title="Mais Ações">
|
||||
<span className="sr-only">Mais Ações</span>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Agendar Consulta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -269,8 +223,7 @@ export default function DoctorsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@ -280,18 +233,14 @@ export default function DoctorsPage() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700" disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
Excluir
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
|
||||
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@ -301,23 +250,22 @@ export default function DoctorsPage() {
|
||||
<div className="space-y-3 text-left">
|
||||
<h3 className="font-semibold mt-2">Informações Principais</h3>
|
||||
<div className="grid grid-cols-2 gap-y-1 gap-x-4 text-sm">
|
||||
<div><strong>CRM:</strong> {doctorDetails.crm}</div>
|
||||
<div><strong>Especialidade:</strong> {doctorDetails.especialidade}</div>
|
||||
<div><strong>Celular:</strong> {doctorDetails.contato.celular || 'N/A'}</div>
|
||||
<div><strong>Localização:</strong> {`${doctorDetails.endereco.cidade || 'N/A'}/${doctorDetails.endereco.estado || 'N/A'}`}</div>
|
||||
<div><strong>CRM:</strong> {doctorDetails.crm}</div>
|
||||
<div><strong>Especialidade:</strong> {doctorDetails.especialidade}</div>
|
||||
<div><strong>Celular:</strong> {doctorDetails.contato.celular || "N/A"}</div>
|
||||
<div><strong>Localização:</strong> {`${doctorDetails.endereco.cidade || "N/A"}/${doctorDetails.endereco.estado || "N/A"}`}</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mt-4">Atendimento e Convênio</h3>
|
||||
<div className="grid grid-cols-2 gap-y-1 gap-x-4 text-sm">
|
||||
<div><strong>Convênio:</strong> {doctorDetails.convenio || 'N/A'}</div>
|
||||
<div><strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"}</div>
|
||||
<div><strong>Status:</strong> {doctorDetails.status || 'N/A'}</div>
|
||||
<div><strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || 'N/A'}</div>
|
||||
<div><strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || 'N/A'}</div>
|
||||
<div><strong>Convênio:</strong> {doctorDetails.convenio || "N/A"}</div>
|
||||
<div><strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"}</div>
|
||||
<div><strong>Status:</strong> {doctorDetails.status || "N/A"}</div>
|
||||
<div><strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || "N/A"}</div>
|
||||
<div><strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || "N/A"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{doctorDetails === null && !loading && (
|
||||
{doctorDetails === null && !isLoading && (
|
||||
<div className="text-red-600">Detalhes não disponíveis.</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
@ -328,6 +276,5 @@ export default function DoctorsPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/manager/layout.tsx
Normal file
120
app/manager/layout.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
// app/manager/layout.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import DashboardLayout from "@/components/layout/DashboardLayout";
|
||||
import { dashboardConfig } from "@/config/dashboard.config";
|
||||
|
||||
interface UserData {
|
||||
id?: string | number;
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
// mesmo tipo que o DashboardLayout espera
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
secondaryText: string;
|
||||
avatarFallback: string;
|
||||
}
|
||||
|
||||
interface ManagerLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const userData: UserData = await usuariosApi.getCurrentUser();
|
||||
const cfg = dashboardConfig?.manager;
|
||||
|
||||
let profile: UserProfile;
|
||||
|
||||
if (cfg && typeof cfg.getUserProfile === "function") {
|
||||
const mapped = cfg.getUserProfile(userData);
|
||||
|
||||
// Garante compatibilidade com o tipo exigido pelo DashboardLayout
|
||||
profile = {
|
||||
name: mapped?.name ?? userData.full_name ?? "Usuário",
|
||||
secondaryText: mapped?.secondaryText ?? userData.email ?? "",
|
||||
avatarFallback:
|
||||
mapped?.avatarFallback ??
|
||||
(userData.full_name
|
||||
? userData.full_name.charAt(0).toUpperCase()
|
||||
: "U"),
|
||||
};
|
||||
} else {
|
||||
// fallback simples
|
||||
profile = {
|
||||
name: userData.full_name ?? "Usuário",
|
||||
secondaryText: userData.email ?? "",
|
||||
avatarFallback: userData.full_name
|
||||
? userData.full_name.charAt(0).toUpperCase()
|
||||
: "U",
|
||||
};
|
||||
}
|
||||
|
||||
if (mounted) setUserProfile(profile);
|
||||
} catch (err: any) {
|
||||
console.error("Erro autenticação (manager layout):", err);
|
||||
if (mounted) {
|
||||
setError(err?.message ?? "Erro ao autenticar");
|
||||
try {
|
||||
router.push("/login");
|
||||
} catch {}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Verificando autenticação...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background p-4">
|
||||
<div>
|
||||
<p className="text-destructive mb-2">Erro: {error}</p>
|
||||
<p className="text-sm text-muted-foreground">Redirecionando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
const menuItems = dashboardConfig?.manager?.menuItems ?? [];
|
||||
|
||||
return (
|
||||
<DashboardLayout menuItems={menuItems} userProfile={userProfile}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Caminho: app/(manager)/login/page.tsx
|
||||
|
||||
import { LoginForm } from "@/components/LoginForm";
|
||||
import Link from "next/link"; // Adicionado para o link de "Voltar"
|
||||
|
||||
export default function ManagerLoginPage() {
|
||||
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
|
||||
// O ideal no futuro é deletar esta página e redirecionar os usuários.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Área do Gestor</h1>
|
||||
<p className="text-muted-foreground mb-8">Acesse o sistema médico</p>
|
||||
|
||||
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
|
||||
{/* Chamando o LoginForm unificado sem props desnecessárias */}
|
||||
<LoginForm>
|
||||
{/* Adicionamos um link de "Voltar" como filho (children) */}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/">
|
||||
<span className="font-semibold text-primary hover:underline cursor-pointer">
|
||||
Voltar à página inicial
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</LoginForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,279 +1,87 @@
|
||||
"use client"
|
||||
// app/manager/usuario/[id]/editar/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Save, Loader2, ArrowLeft } from "lucide-react"
|
||||
import ManagerLayout from "@/components/manager-layout"
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import { perfisApi } from "@/services/perfisApi";
|
||||
|
||||
// Mock user service for demonstration. Replace with your actual API service.
|
||||
const usersService = {
|
||||
getById: async (id: string): Promise<any> => {
|
||||
console.log(`API Call: Fetching user with ID ${id}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// This mock finds a user from a predefined list.
|
||||
const mockUsers = [
|
||||
{ id: 1, full_name: 'Alice Admin', email: 'alice.admin@example.com', phone: '(11) 98765-4321', role: 'admin' },
|
||||
{ id: 2, full_name: 'Bruno Gestor', email: 'bruno.g@example.com', phone: '(21) 91234-5678', role: 'gestor' },
|
||||
{ id: 3, full_name: 'Dr. Carlos Médico', email: 'carlos.med@example.com', phone: null, role: 'medico' },
|
||||
{ id: 4, full_name: 'Daniela Secretaria', email: 'daniela.sec@example.com', phone: '(31) 99999-8888', role: 'secretaria' },
|
||||
{ id: 5, full_name: 'Eduardo Usuário', email: 'edu.user@example.com', phone: '(41) 98888-7777', role: 'user' },
|
||||
];
|
||||
const user = mockUsers.find(u => u.id.toString() === id);
|
||||
if (!user) throw new Error("Usuário não encontrado.");
|
||||
return user;
|
||||
},
|
||||
update: async (id: string, payload: any): Promise<void> => {
|
||||
console.log(`API Call: Updating user ${id} with payload:`, payload);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// To simulate an error (e.g., duplicate email), you could throw an error here:
|
||||
// if (payload.email === 'bruno.g@example.com') throw new Error("Este e-mail já está em uso por outro usuário.");
|
||||
}
|
||||
};
|
||||
|
||||
// Interface for the user form data
|
||||
interface UserFormData {
|
||||
nomeCompleto: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
papel: string;
|
||||
password?: string; // Optional for password updates
|
||||
interface Profile {
|
||||
id?: number | string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
// Default state for the form
|
||||
const defaultFormData: UserFormData = {
|
||||
nomeCompleto: '',
|
||||
email: '',
|
||||
telefone: '',
|
||||
papel: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
// Helper functions for phone formatting
|
||||
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
|
||||
const formatPhone = (value: string): string => {
|
||||
const cleaned = cleanNumber(value).substring(0, 11);
|
||||
if (cleaned.length > 10) {
|
||||
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
|
||||
}
|
||||
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
|
||||
};
|
||||
|
||||
export default function EditarUsuarioPage() {
|
||||
const router = useRouter();
|
||||
export default function ManagerUsuarioEditPage() {
|
||||
const params = useParams();
|
||||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
|
||||
const [formData, setFormData] = useState<UserFormData>(defaultFormData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const id = params?.id;
|
||||
const router = useRouter();
|
||||
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Map API field names to our form field names
|
||||
const apiToFormMap: { [key: string]: keyof UserFormData } = {
|
||||
'full_name': 'nomeCompleto',
|
||||
'email': 'email',
|
||||
'phone': 'telefone',
|
||||
'role': 'papel'
|
||||
};
|
||||
|
||||
// Fetch user data when the component mounts
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchUser = async () => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usersService.getById(id);
|
||||
if (!data) {
|
||||
setError("Usuário não encontrado.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialData: Partial<UserFormData> = {};
|
||||
Object.keys(data).forEach(key => {
|
||||
const formKey = apiToFormMap[key];
|
||||
if (formKey) {
|
||||
initialData[formKey] = data[key] === null ? '' : String(data[key]);
|
||||
}
|
||||
});
|
||||
|
||||
setFormData(prev => ({ ...prev, ...initialData }));
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao carregar dados do usuário:", e);
|
||||
setError(e.message || "Não foi possível carregar os dados do usuário.");
|
||||
if (!id) throw new Error("ID ausente");
|
||||
const full = await usuariosApi.getFullData(String(id));
|
||||
// getFullData pode retornar objeto com profile
|
||||
const prof = (full && full.profile) ? full.profile : full;
|
||||
if (mounted) setProfile(prof ?? null);
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao buscar usuário:", err);
|
||||
if (mounted) setError(err?.message ?? "Erro ao buscar usuário");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
const handleInputChange = (key: keyof UserFormData, value: string) => {
|
||||
const updatedValue = key === 'telefone' ? formatPhone(value) : value;
|
||||
setFormData((prev) => ({ ...prev, [key]: updatedValue }));
|
||||
};
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!id || !profile) return;
|
||||
setIsSaving(true);
|
||||
|
||||
if (!id) {
|
||||
setError("ID do usuário ausente.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare payload for the API
|
||||
const payload: { [key: string]: any } = {
|
||||
full_name: formData.nomeCompleto,
|
||||
email: formData.email,
|
||||
phone: formData.telefone.trim() || null,
|
||||
role: formData.papel,
|
||||
};
|
||||
|
||||
// Only include the password in the payload if it has been changed
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await usersService.update(id, payload);
|
||||
router.push("/manager/usuario");
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao salvar o usuário:", e);
|
||||
setError(e.message || "Ocorreu um erro inesperado ao atualizar.");
|
||||
await perfisApi.update(String(id), profile);
|
||||
router.push("/manager/usuario");
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao atualizar perfil:", err);
|
||||
setError(err?.message ?? "Erro ao salvar");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="flex justify-center items-center h-full w-full py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-600" />
|
||||
<p className="ml-2 text-gray-600">Carregando dados do usuário...</p>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
);
|
||||
}
|
||||
if (isLoading) return <div className="p-8">Carregando...</div>;
|
||||
if (error) return <div className="p-8 text-destructive">Erro: {error}</div>;
|
||||
if (!profile) return <div className="p-8">Usuário não encontrado.</div>;
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="w-full max-w-2xl mx-auto space-y-6 p-4 md:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Editar Usuário: <span className="text-green-600">{formData.nomeCompleto}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Atualize as informações do usuário (ID: {id}).
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/manager/usuario">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</Link>
|
||||
<main className="w-full p-4 md:p-8">
|
||||
<div className="max-w-screen-md mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4">Editar Usuário</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 border rounded">
|
||||
<div>
|
||||
<label className="block text-sm">Nome completo</label>
|
||||
<input value={profile.full_name ?? ""} onChange={(e) => setProfile({ ...profile, full_name: e.target.value })} required className="w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => router.push("/manager/usuario")} className="mr-2">Cancelar</button>
|
||||
<button type="submit" disabled={isSaving}>{isSaving ? "Salvando..." : "Salvar"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8 bg-white p-8 border rounded-lg shadow-sm">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
|
||||
<p className="font-medium">Erro na Atualização:</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nomeCompleto">Nome Completo</Label>
|
||||
<Input
|
||||
id="nomeCompleto"
|
||||
value={formData.nomeCompleto}
|
||||
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Nova Senha</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
placeholder="Deixe em branco para não alterar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telefone">Telefone</Label>
|
||||
<Input
|
||||
id="telefone"
|
||||
value={formData.telefone}
|
||||
onChange={(e) => handleInputChange("telefone", e.target.value)}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="papel">Papel (Função)</Label>
|
||||
<Select value={formData.papel} onValueChange={(v) => handleInputChange("papel", v)}>
|
||||
<SelectTrigger id="papel">
|
||||
<SelectValue placeholder="Selecione uma função" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="gestor">Gestor</SelectItem>
|
||||
<SelectItem value="medico">Médico</SelectItem>
|
||||
<SelectItem value="secretaria">Secretaria</SelectItem>
|
||||
<SelectItem value="user">Usuário</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Link href="/manager/usuario">
|
||||
<Button type="button" variant="outline" disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isSaving ? "Salvando..." : "Salvar Alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Caminho: manager/usuario/novo/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
@ -14,9 +15,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
import ManagerLayout from "@/components/manager-layout";
|
||||
import { usersService } from "services/usersApi.mjs";
|
||||
import { login } from "services/api.mjs";
|
||||
// 🔧 Correção: importava DashboardLayout, mas deve importar ManagerLayout
|
||||
import ManagerLayout from "@/app/manager/layout";
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
// Removido import incorreto
|
||||
// import { api } from "services/api";
|
||||
|
||||
interface UserFormData {
|
||||
email: string;
|
||||
@ -60,7 +63,6 @@ export default function NovoUsuarioPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (
|
||||
!formData.email ||
|
||||
!formData.nomeCompleto ||
|
||||
@ -71,29 +73,24 @@ export default function NovoUsuarioPage() {
|
||||
setError("Por favor, preencha todos os campos obrigatórios.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.senha !== formData.confirmarSenha) {
|
||||
setError("A Senha e a Confirmação de Senha não coincidem.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await login();
|
||||
const payload = {
|
||||
email: formData.email.trim().toLowerCase(),
|
||||
full_name: formData.nomeCompleto,
|
||||
phone: formData.telefone || null,
|
||||
phone_mobile: formData.telefone || null,
|
||||
role: formData.papel || "paciente",
|
||||
cpf: "00000000000",
|
||||
create_patient_record: true
|
||||
};
|
||||
|
||||
const payload = {
|
||||
full_name: formData.nomeCompleto,
|
||||
email: formData.email.trim().toLowerCase(),
|
||||
phone: formData.telefone || null,
|
||||
role: formData.papel,
|
||||
password: formData.senha,
|
||||
};
|
||||
|
||||
console.log("📤 Enviando payload:", payload);
|
||||
|
||||
await usersService.create_user(payload);
|
||||
|
||||
await usuariosApi.createUser(payload);
|
||||
router.push("/manager/usuario");
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao criar usuário:", e);
|
||||
@ -176,7 +173,7 @@ export default function NovoUsuarioPage() {
|
||||
<SelectItem value="gestor">Gestor</SelectItem>
|
||||
<SelectItem value="medico">Médico</SelectItem>
|
||||
<SelectItem value="secretaria">Secretária</SelectItem>
|
||||
<SelectItem value="user">Usuário</SelectItem>
|
||||
<SelectItem value="user">Paciente</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -1,29 +1,18 @@
|
||||
// app/manager/usuario/page.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import ManagerLayout from "@/components/manager-layout";
|
||||
// REMOVIDO: import ManagerLayout, pois a página já é envolvida pelo layout pai.
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Eye, Filter, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { api, login } from "services/api.mjs";
|
||||
import { usersService } from "services/usersApi.mjs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Eye, Filter, Loader2 } from "lucide-react";
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
|
||||
// Assumindo caminhos de importação do seu projeto
|
||||
import { api } from "services/api";
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import { perfisApi } from "@/services/perfisApi";
|
||||
import { UserRole } from "@/services/usuariosApi";
|
||||
|
||||
interface FlatUser {
|
||||
id: string;
|
||||
@ -48,45 +37,70 @@ export default function UsersPage() {
|
||||
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
|
||||
const [userDetails, setUserDetails] = useState<UserInfoResponse | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<string>("");
|
||||
// Estado para armazenar papéis disponíveis dinamicamente
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
|
||||
// Função utilitária para capitalizar a primeira letra
|
||||
const capitalize = (s: string) => {
|
||||
if (!s) return s;
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
};
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 1) pega roles
|
||||
const rolesData: any[] = await usersService.list_roles();
|
||||
// Garante que rolesData é array
|
||||
const rolesArray = Array.isArray(rolesData) ? rolesData : [];
|
||||
// 1) Pega papéis e perfis em paralelo para melhor performance
|
||||
const [rolesData, profilesData] = await Promise.all([
|
||||
usuariosApi.listRoles(),
|
||||
perfisApi.list()
|
||||
]);
|
||||
|
||||
const rolesArray: UserRole[] = Array.isArray(rolesData) ? rolesData : [];
|
||||
|
||||
// 2) Extrair e salvar papéis únicos para o filtro (NOVO)
|
||||
const uniqueRoles = new Set<string>();
|
||||
rolesArray.forEach(roleItem => {
|
||||
// Usa roleItem.role, se existir
|
||||
if (roleItem.role) {
|
||||
uniqueRoles.add(roleItem.role);
|
||||
}
|
||||
});
|
||||
// Converter para array, ordenar e atualizar o estado
|
||||
setAvailableRoles(Array.from(uniqueRoles).sort());
|
||||
|
||||
// 2) pega todos os profiles de uma vez (para evitar muitos requests)
|
||||
const profilesData: any[] = await api.get(`/rest/v1/profiles?select=id,full_name,email,phone`);
|
||||
const profilesById = new Map<string, any>();
|
||||
if (Array.isArray(profilesData)) {
|
||||
for (const p of profilesData) {
|
||||
// A chave do perfil deve ser o user_id, conforme a lógica anterior
|
||||
if (p?.id) profilesById.set(p.id, p);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) mapear roles -> flat users, usando ID específico de cada item
|
||||
// 3) Mapear roles -> flat users
|
||||
const mapped: FlatUser[] = rolesArray.map((roleItem) => {
|
||||
const uid = roleItem.user_id;
|
||||
const profile = profilesById.get(uid);
|
||||
|
||||
// Determina o role a ser usado. Prioriza roleItem.role, se não, '—'
|
||||
const role = roleItem.role ?? "—";
|
||||
|
||||
return {
|
||||
id: uid,
|
||||
user_id: uid,
|
||||
full_name: profile?.full_name ?? "—",
|
||||
email: profile?.email ?? "—",
|
||||
phone: profile?.phone ?? "—",
|
||||
role: roleItem.role ?? "—",
|
||||
role: role,
|
||||
};
|
||||
});
|
||||
|
||||
setUsers(mapped);
|
||||
console.log("[fetchUsers] mapped count:", mapped.length);
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao buscar usuários:", err);
|
||||
setError("Não foi possível carregar os usuários. Veja console.");
|
||||
setError("Não foi possível carregar os usuários. Verifique o console.");
|
||||
setUsers([]);
|
||||
setAvailableRoles([]); // Limpa os papéis em caso de erro
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -94,11 +108,13 @@ export default function UsersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await login(); // garante token
|
||||
} catch (e) {
|
||||
console.warn("login falhou no init:", e);
|
||||
// Garante que o interceptor da API seja executado para ler o cookie
|
||||
try {
|
||||
await api.get('/');
|
||||
} catch (e) {
|
||||
console.warn("API setup (leitura de cookie via interceptor) falhou ou o endpoint raiz não existe:", e);
|
||||
}
|
||||
|
||||
await fetchUsers();
|
||||
};
|
||||
init();
|
||||
@ -107,20 +123,18 @@ export default function UsersPage() {
|
||||
const openDetailsDialog = async (flatUser: FlatUser) => {
|
||||
setDetailsDialogOpen(true);
|
||||
setUserDetails(null);
|
||||
|
||||
try {
|
||||
console.log("[openDetailsDialog] user_id:", flatUser.user_id);
|
||||
const data = await usersService.full_data(flatUser.user_id);
|
||||
console.log("[openDetailsDialog] full_data returned:", data);
|
||||
// O getFullData usa user_id
|
||||
const data = await usuariosApi.getFullData(flatUser.user_id);
|
||||
setUserDetails(data);
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao carregar detalhes:", err);
|
||||
// fallback com dados já conhecidos
|
||||
// Fallback details em caso de falha na API
|
||||
setUserDetails({
|
||||
user: { id: flatUser.user_id, email: flatUser.email },
|
||||
profile: { full_name: flatUser.full_name, phone: flatUser.phone },
|
||||
roles: [flatUser.role],
|
||||
permissions: {},
|
||||
permissions: { "read:self": true, "write:profile": false },
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -128,117 +142,104 @@ export default function UsersPage() {
|
||||
const filteredUsers =
|
||||
selectedRole && selectedRole !== "all" ? users.filter((u) => u.role === selectedRole) : users;
|
||||
|
||||
// REMOVIDO: mockUserProfile e mockMenuItems não são mais necessários
|
||||
// pois o ManagerLayout deve ser fornecido pelo arquivo app/manager/layout.tsx
|
||||
|
||||
|
||||
return (
|
||||
<ManagerLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
|
||||
<p className="text-sm text-gray-500">Gerencie usuários.</p>
|
||||
</div>
|
||||
<Link href="/manager/usuario/novo">
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
<Plus className="w-4 h-4 mr-2" /> Novo Usuário
|
||||
</Button>
|
||||
</Link>
|
||||
// CORRIGIDO: Retornando apenas o conteúdo da página, sem o ManagerLayout
|
||||
<div className="space-y-6">
|
||||
{/* Conteúdo da página */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
|
||||
<p className="text-sm text-gray-500">Gerencie usuários.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<Filter className="w-5 h-5 text-gray-400" />
|
||||
<Select onValueChange={setSelectedRole} value={selectedRole}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filtrar por papel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="gestor">Gestor</SelectItem>
|
||||
<SelectItem value="medico">Médico</SelectItem>
|
||||
<SelectItem value="secretaria">Secretária</SelectItem>
|
||||
<SelectItem value="user">Usuário</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
|
||||
Carregando usuários...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600">{error}</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Nenhum usuário encontrado.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">E-mail</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefone</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cargo</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.id}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.full_name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.email}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.phone}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{u.role}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Button variant="outline" size="icon" onClick={() => openDetailsDialog(u)} title="Visualizar">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl">{userDetails?.profile?.full_name || "Detalhes do Usuário"}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{!userDetails ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-green-600" />
|
||||
Buscando dados completos...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 pt-2 text-left text-gray-700">
|
||||
<div><strong>ID:</strong> {userDetails.user.id}</div>
|
||||
<div><strong>E-mail:</strong> {userDetails.user.email}</div>
|
||||
<div><strong>Nome completo:</strong> {userDetails.profile.full_name}</div>
|
||||
<div><strong>Telefone:</strong> {userDetails.profile.phone}</div>
|
||||
<div><strong>Roles:</strong> {userDetails.roles?.join(", ")}</div>
|
||||
<div>
|
||||
<strong>Permissões:</strong>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.entries(userDetails.permissions || {}).map(([k,v]) => <li key={k}>{k}: {v ? "Sim" : "Não"}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Fechar</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Link href="/manager/usuario/novo">
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
<Plus className="w-4 h-4 mr-2" /> Novo Usuário
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</ManagerLayout>
|
||||
{/* Filtros */}
|
||||
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<Filter className="w-5 h-5 text-gray-400" />
|
||||
<Select onValueChange={setSelectedRole} value={selectedRole}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Filtrar por papel" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
{availableRoles.map(role => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{capitalize(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Tabela de Usuários */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
|
||||
{loading
|
||||
? <div className="p-8 text-center text-gray-500"><Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />Carregando usuários...</div>
|
||||
: error
|
||||
? <div className="p-8 text-center text-red-600">{error}</div>
|
||||
: filteredUsers.length === 0
|
||||
? <div className="p-8 text-center text-gray-500">Nenhum usuário encontrado{selectedRole && selectedRole !== 'all' ? ` para o papel: ${capitalize(selectedRole)}` : '.'}</div>
|
||||
: <div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">E-mail</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefone</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cargo</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.id}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.full_name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.email}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.phone}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{capitalize(u.role)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Button variant="outline" size="icon" onClick={() => openDetailsDialog(u)} title="Visualizar">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
{/* Diálogo de Detalhes */}
|
||||
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl">{userDetails?.profile?.full_name || "Detalhes do Usuário"}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{!userDetails
|
||||
? <div className="p-4 text-center text-gray-500"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-green-600" />Buscando dados completos...</div>
|
||||
: <div className="space-y-3 pt-2 text-left text-gray-700">
|
||||
<div><strong>ID:</strong> {userDetails.user.id}</div>
|
||||
<div><strong>E-mail:</strong> {userDetails.user.email}</div>
|
||||
<div><strong>Nome completo:</strong> {userDetails.profile.full_name}</div>
|
||||
<div><strong>Telefone:</strong> {userDetails.profile.phone}</div>
|
||||
<div><strong>Roles:</strong> {userDetails.roles?.map(capitalize).join(", ")}</div>
|
||||
<div><strong>Permissões:</strong>
|
||||
<ul className="list-disc list-inside mt-1 ml-4 text-sm">
|
||||
{Object.entries(userDetails.permissions || {}).map(([k,v]) => <li key={k}>{k}: <strong className={`${v ? 'text-green-600' : 'text-red-600'}`}>{v ? "Sim" : "Não"}</strong></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Fechar</AlertDialogCancel></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// app/page.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link"
|
||||
@ -17,8 +19,8 @@ export default function InicialPage() {
|
||||
<header className="bg-card shadow-md py-4 px-6 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-primary">MediConnect</h1>
|
||||
<nav className="flex space-x-6 text-muted-foreground font-medium">
|
||||
<a href="#home" className="hover:text-primary"><Link href="/cadastro">Home</Link></a>
|
||||
<a href="#about" className="hover:text-primary">Sobre</a>
|
||||
<a href="#home" className="hover:text-blue-600">Home</a>
|
||||
<Link href="/dev/api-check" className="hover:text-primary">Sobre (API Test)</Link>
|
||||
<a href="#departments" className="hover:text-primary">Departamentos</a>
|
||||
<a href="#doctors" className="hover:text-primary">Médicos</a>
|
||||
<a href="#contact" className="hover:text-primary">Contato</a>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
// Caminho: app/(patient)/appointments/page.tsx (Corrigido e Alinhado com a API Real)
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PatientLayout from "@/components/patient-layout";
|
||||
import type React from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -10,334 +11,245 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar, Clock, MapPin, Phone, User, X, CalendarDays } from "lucide-react";
|
||||
import { Calendar, Clock, MapPin, Phone, X, CalendarDays } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { appointmentsService } from "@/services/appointmentsApi.mjs";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { doctorsService } from "@/services/doctorsApi.mjs";
|
||||
import { usuariosApi, User } from "@/services/usuariosApi";
|
||||
import { agendamentosApi, Appointment } from "@/services/agendamentosApi";
|
||||
|
||||
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
|
||||
// --- FUNÇÃO AUXILIAR ---
|
||||
const isAppointmentInPast = (scheduledAt: string): boolean => {
|
||||
const now = new Date();
|
||||
const appointmentDate = new Date(scheduledAt);
|
||||
now.setHours(0, 0, 0, 0);
|
||||
appointmentDate.setHours(0, 0, 0, 0);
|
||||
return appointmentDate < now;
|
||||
};
|
||||
|
||||
// Simulação do paciente logado
|
||||
const LOGGED_PATIENT_ID = "P001";
|
||||
// --- Componente Reutilizável para o Card de Agendamento ---
|
||||
const AppointmentCard: React.FC<{
|
||||
appointment: Appointment;
|
||||
onReschedule: (appt: Appointment) => void;
|
||||
onCancel: (appt: Appointment) => void;
|
||||
}> = ({ appointment, onReschedule, onCancel }) => {
|
||||
|
||||
const getStatusBadge = (status: string): React.ReactNode => {
|
||||
switch (status) {
|
||||
case "requested": return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80">Solicitada</Badge>;
|
||||
case "confirmed": return <Badge className="bg-primary text-primary-foreground hover:bg-primary/90">Confirmada</Badge>;
|
||||
case "completed": return <Badge variant="secondary">Realizada</Badge>;
|
||||
case "cancelled": return <Badge variant="destructive">Cancelada</Badge>;
|
||||
default: return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const isMock = appointment.id.startsWith("mock-");
|
||||
const isPast = isAppointmentInPast(appointment.scheduled_at);
|
||||
const canBeModified = !isPast && appointment.status !== "cancelled" && appointment.status !== "completed";
|
||||
|
||||
return (
|
||||
<Card className={isMock ? "border-dashed bg-muted/30" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{appointment.doctors?.full_name || "Médico não encontrado"}
|
||||
{isMock && <span className="ml-2 text-xs font-normal text-muted-foreground">(Exemplo)</span>}
|
||||
</CardTitle>
|
||||
<CardDescription>{appointment.doctors?.specialty || "Especialidade não informada"}</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-3 text-sm text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center"><Calendar className="mr-2 h-4 w-4" /> {new Date(appointment.scheduled_at).toLocaleDateString("pt-BR", { timeZone: "UTC" })}</div>
|
||||
<div className="flex items-center"><Clock className="mr-2 h-4 w-4" /> {new Date(appointment.scheduled_at).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" })}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center"><MapPin className="mr-2 h-4 w-4" /> {appointment.appointment_type === 'telemedicina' ? 'Link da videochamada' : 'Clínica Central - Sala 101'}</div>
|
||||
<div className="flex items-center"><Phone className="mr-2 h-4 w-4" /> (11) 99999-9999</div>
|
||||
</div>
|
||||
</div>
|
||||
{canBeModified && (
|
||||
<div className="flex gap-2 mt-4 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => onReschedule(appointment)}><CalendarDays className="mr-2 h-4 w-4" /> Reagendar</Button>
|
||||
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onCancel(appointment)}><X className="mr-2 h-4 w-4" /> Cancelar</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Componente Principal da Página ---
|
||||
export default function PatientAppointments() {
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||
const [availableSlots, setAvailableSlots] = useState<string[]>([]);
|
||||
|
||||
// Modais
|
||||
const [rescheduleModal, setRescheduleModal] = useState(false);
|
||||
const [cancelModal, setCancelModal] = useState(false);
|
||||
|
||||
// Formulário de reagendamento/cancelamento
|
||||
const [isRescheduleModalOpen, setRescheduleModalOpen] = useState(false);
|
||||
const [isCancelModalOpen, setCancelModalOpen] = useState(false);
|
||||
const [rescheduleData, setRescheduleData] = useState({ date: "", time: "", reason: "" });
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
const timeSlots = [
|
||||
"08:00",
|
||||
"08:30",
|
||||
"09:00",
|
||||
"09:30",
|
||||
"10:00",
|
||||
"10:30",
|
||||
"11:00",
|
||||
"11:30",
|
||||
"14:00",
|
||||
"14:30",
|
||||
"15:00",
|
||||
"15:30",
|
||||
"16:00",
|
||||
"16:30",
|
||||
"17:00",
|
||||
"17:30",
|
||||
];
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!user?.id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [appointmentList, patientList, doctorList] = await Promise.all([
|
||||
appointmentsService.list(),
|
||||
patientsService.list(),
|
||||
doctorsService.list(),
|
||||
]);
|
||||
|
||||
const doctorMap = new Map(doctorList.map((d: any) => [d.id, d]));
|
||||
const patientMap = new Map(patientList.map((p: any) => [p.id, p]));
|
||||
|
||||
// Filtra apenas as consultas do paciente logado
|
||||
const patientAppointments = appointmentList
|
||||
.filter((apt: any) => apt.patient_id === LOGGED_PATIENT_ID)
|
||||
.map((apt: any) => ({
|
||||
...apt,
|
||||
doctor: doctorMap.get(apt.doctor_id) || { full_name: "Médico não encontrado", specialty: "N/A" },
|
||||
patient: patientMap.get(apt.patient_id) || { full_name: "Paciente não encontrado" },
|
||||
}));
|
||||
let patientAppointments = await agendamentosApi.listByPatient(user.id);
|
||||
|
||||
if (patientAppointments.length === 0) {
|
||||
console.warn("Nenhum agendamento encontrado na API real. Buscando do mock...");
|
||||
toast.info("Usando dados de exemplo para a lista de consultas.");
|
||||
patientAppointments = await agendamentosApi.getMockAppointments();
|
||||
}
|
||||
|
||||
setAppointments(patientAppointments);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar consultas:", error);
|
||||
toast.error("Não foi possível carregar suas consultas.");
|
||||
toast.error("Não foi possível carregar suas consultas. Tentando usar dados de exemplo.");
|
||||
try {
|
||||
const mockAppointments = await agendamentosApi.getMockAppointments();
|
||||
setAppointments(mockAppointments);
|
||||
} catch (mockError) {
|
||||
console.error("Falha ao buscar dados do mock:", mockError);
|
||||
setAppointments([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const currentUser = await usuariosApi.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch (error) {
|
||||
console.error("Usuário não autenticado:", error);
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
loadInitialData();
|
||||
}, [fetchData]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "requested":
|
||||
return <Badge className="bg-yellow-100 text-yellow-800">Solicitada</Badge>;
|
||||
case "confirmed":
|
||||
return <Badge className="bg-blue-100 text-blue-800">Confirmada</Badge>;
|
||||
case "checked_in":
|
||||
return <Badge className="bg-indigo-100 text-indigo-800">Check-in</Badge>;
|
||||
case "completed":
|
||||
return <Badge className="bg-green-100 text-green-800">Realizada</Badge>;
|
||||
case "cancelled":
|
||||
return <Badge className="bg-red-100 text-red-800">Cancelada</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
}, [user, fetchData]);
|
||||
|
||||
const handleReschedule = (appointment: any) => {
|
||||
useEffect(() => {
|
||||
if (rescheduleData.date && selectedAppointment?.doctor_id && selectedAppointment.id.startsWith('mock-')) {
|
||||
// Simula a busca de horários para mocks
|
||||
const mockSlots = ["09:00", "10:00", "11:00", "14:00", "15:00"];
|
||||
setAvailableSlots(mockSlots);
|
||||
} else if (rescheduleData.date && selectedAppointment?.doctor_id) {
|
||||
agendamentosApi.getAvailableSlots(selectedAppointment.doctor_id, rescheduleData.date)
|
||||
.then(response => {
|
||||
const slots = response.slots.filter(s => s.available).map(s => s.time);
|
||||
setAvailableSlots(slots);
|
||||
})
|
||||
.catch(() => toast.error("Não foi possível buscar horários para esta data."));
|
||||
}
|
||||
}, [rescheduleData.date, selectedAppointment?.doctor_id, selectedAppointment?.id]);
|
||||
|
||||
const handleReschedule = (appointment: Appointment) => {
|
||||
setSelectedAppointment(appointment);
|
||||
setRescheduleData({ date: "", time: "", reason: "" });
|
||||
setRescheduleModal(true);
|
||||
setAvailableSlots([]);
|
||||
setRescheduleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCancel = (appointment: any) => {
|
||||
const handleCancel = (appointment: Appointment) => {
|
||||
setSelectedAppointment(appointment);
|
||||
setCancelReason("");
|
||||
setCancelModal(true);
|
||||
setCancelModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmReschedule = async () => {
|
||||
if (!rescheduleData.date || !rescheduleData.time) {
|
||||
toast.error("Por favor, selecione uma nova data e horário.");
|
||||
return;
|
||||
if (!selectedAppointment || !rescheduleData.date || !rescheduleData.time) {
|
||||
return toast.error("Por favor, selecione uma nova data e horário.");
|
||||
}
|
||||
try {
|
||||
const newScheduledAt = new Date(`${rescheduleData.date}T${rescheduleData.time}:00Z`).toISOString();
|
||||
|
||||
const isMock = selectedAppointment.id.startsWith("mock-");
|
||||
const newScheduledAt = new Date(`${rescheduleData.date}T${rescheduleData.time}:00Z`).toISOString();
|
||||
|
||||
await appointmentsService.update(selectedAppointment.id, {
|
||||
scheduled_at: newScheduledAt,
|
||||
status: "requested",
|
||||
});
|
||||
|
||||
setAppointments((prev) =>
|
||||
prev.map((apt) =>
|
||||
apt.id === selectedAppointment.id ? { ...apt, scheduled_at: newScheduledAt, status: "requested" } : apt
|
||||
if (isMock) {
|
||||
setAppointments(prev =>
|
||||
prev.map(apt =>
|
||||
apt.id === selectedAppointment.id
|
||||
? { ...apt, scheduled_at: newScheduledAt, status: "requested" as const }
|
||||
: apt
|
||||
)
|
||||
);
|
||||
|
||||
setRescheduleModal(false);
|
||||
toast.success("Consulta reagendada com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Erro ao reagendar consulta:", error);
|
||||
toast.error("Não foi possível reagendar a consulta.");
|
||||
setRescheduleModalOpen(false);
|
||||
toast.success("Consulta de exemplo reagendada!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Lógica para dados reais, informando que a funcionalidade não existe
|
||||
toast.warning("Funcionalidade indisponível.", { description: "A API não possui um endpoint para reagendar consultas." });
|
||||
setRescheduleModalOpen(false);
|
||||
};
|
||||
|
||||
const confirmCancel = async () => {
|
||||
if (!cancelReason.trim() || cancelReason.trim().length < 10) {
|
||||
toast.error("Por favor, informe um motivo de cancelamento (mínimo 10 caracteres).");
|
||||
return;
|
||||
if (!selectedAppointment || cancelReason.trim().length < 10) {
|
||||
return toast.error("Por favor, informe um motivo com no mínimo 10 caracteres.");
|
||||
}
|
||||
try {
|
||||
await appointmentsService.update(selectedAppointment.id, {
|
||||
status: "cancelled",
|
||||
cancel_reason: cancelReason,
|
||||
});
|
||||
|
||||
setAppointments((prev) =>
|
||||
prev.map((apt) =>
|
||||
apt.id === selectedAppointment.id ? { ...apt, status: "cancelled" } : apt
|
||||
const isMock = selectedAppointment.id.startsWith("mock-");
|
||||
|
||||
if (isMock) {
|
||||
setAppointments(prev =>
|
||||
prev.map(apt =>
|
||||
apt.id === selectedAppointment.id ? { ...apt, status: "cancelled" as const } : apt
|
||||
)
|
||||
);
|
||||
|
||||
setCancelModal(false);
|
||||
toast.success("Consulta cancelada com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Erro ao cancelar consulta:", error);
|
||||
toast.error("Não foi possível cancelar a consulta.");
|
||||
setCancelModalOpen(false);
|
||||
toast.success("Consulta de exemplo cancelada!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Lógica para dados reais, informando que a funcionalidade não existe
|
||||
toast.warning("Funcionalidade indisponível.", { description: "A API não possui um endpoint para cancelar consultas." });
|
||||
setCancelModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PatientLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Minhas Consultas</h1>
|
||||
<p className="text-gray-600">Veja, reagende ou cancele suas consultas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{isLoading ? (
|
||||
<p>Carregando suas consultas...</p>
|
||||
) : appointments.length > 0 ? (
|
||||
appointments.map((appointment) => (
|
||||
<Card key={appointment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{appointment.doctor.full_name}</CardTitle>
|
||||
<CardDescription>{appointment.doctor.specialty}</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{new Date(appointment.scheduled_at).toLocaleDateString("pt-BR", { timeZone: "UTC" })}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{new Date(appointment.scheduled_at).toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPin className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.doctor.location || "Local a definir"}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Phone className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{appointment.doctor.phone || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.status !== "cancelled" && (
|
||||
<div className="flex gap-2 mt-4 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => handleReschedule(appointment)}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
Reagendar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleCancel(appointment)}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-600">Você ainda não possui consultas agendadas.</p>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Minhas Consultas</h1>
|
||||
<p className="text-muted-foreground">Veja, reagende ou cancele suas consultas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL DE REAGENDAMENTO */}
|
||||
<Dialog open={rescheduleModal} onOpenChange={setRescheduleModal}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reagendar Consulta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha uma nova data e horário para sua consulta com{" "}
|
||||
<strong>{selectedAppointment?.doctor?.full_name}</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">Nova Data</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={rescheduleData.date}
|
||||
onChange={(e) => setRescheduleData((prev) => ({ ...prev, date: e.target.value }))}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="time">Novo Horário</Label>
|
||||
<Select
|
||||
value={rescheduleData.time}
|
||||
onValueChange={(value) => setRescheduleData((prev) => ({ ...prev, time: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um horário" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeSlots.map((time) => (
|
||||
<SelectItem key={time} value={time}>
|
||||
{time}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason">Motivo (opcional)</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
placeholder="Explique brevemente o motivo do reagendamento..."
|
||||
value={rescheduleData.reason}
|
||||
onChange={(e) => setRescheduleData((prev) => ({ ...prev, reason: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRescheduleModal(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={confirmReschedule}>Confirmar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid gap-6">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Carregando suas consultas...</p>
|
||||
) : appointments.length > 0 ? (
|
||||
appointments.map((appointment) => (
|
||||
<AppointmentCard key={appointment.id} appointment={appointment} onReschedule={handleReschedule} onCancel={handleCancel} />
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground">Você ainda não possui consultas agendadas.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MODAL DE CANCELAMENTO */}
|
||||
<Dialog open={cancelModal} onOpenChange={setCancelModal}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancelar Consulta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente cancelar sua consulta com{" "}
|
||||
<strong>{selectedAppointment?.doctor?.full_name}</strong>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cancel-reason" className="text-sm font-medium">
|
||||
Motivo do Cancelamento <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
placeholder="Informe o motivo do cancelamento (mínimo 10 caracteres)"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelModal(false)}>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmCancel}>
|
||||
Confirmar Cancelamento
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PatientLayout>
|
||||
{/* ... (Modais de Reagendamento e Cancelamento permanecem os mesmos) ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,113 +1,248 @@
|
||||
import PatientLayout from "@/components/patient-layout"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar, Clock, User, Plus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
// Caminho: app/patient/dashboard/page.tsx
|
||||
|
||||
export default function PatientDashboard() {
|
||||
return (
|
||||
<PatientLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
|
||||
</div>
|
||||
"use client";
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">15 Jan</div>
|
||||
<p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, User, Plus, LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">3</div>
|
||||
<p className="text-xs text-muted-foreground">2 realizadas, 1 agendada</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
// Importando TODOS os serviços de API necessários
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import { agendamentosApi, Appointment as ApiAppointment } from "@/services/agendamentosApi";
|
||||
import { pacientesApi, Patient } from "@/services/pacientesApi";
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<p className="text-xs text-muted-foreground">Dados completos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
// --- Componentes Reutilizáveis ---
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/patient/schedule">
|
||||
<Button className="w-full justify-start">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Agendar Nova Consulta
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/patient/appointments">
|
||||
<Button variant="outline" className="w-full justify-start bg-transparent">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Ver Minhas Consultas
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/patient/profile">
|
||||
<Button variant="outline" className="w-full justify-start bg-transparent">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Atualizar Dados
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximas Consultas</CardTitle>
|
||||
<CardDescription>Suas consultas agendadas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Dr. Silva</p>
|
||||
<p className="text-sm text-gray-600">Cardiologia</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">15 Jan</p>
|
||||
<p className="text-sm text-gray-600">14:30</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Dra. Santos</p>
|
||||
<p className="text-sm text-gray-600">Dermatologia</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">22 Jan</p>
|
||||
<p className="text-sm text-gray-600">10:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PatientLayout>
|
||||
)
|
||||
interface DashboardStatCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const DashboardStatCard: React.FC<DashboardStatCardProps> = ({ title, value, description, icon: Icon }) => (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
interface AppointmentDisplay {
|
||||
doctorName: string;
|
||||
specialty: string;
|
||||
date: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface UpcomingAppointmentItemProps {
|
||||
appointment: AppointmentDisplay;
|
||||
}
|
||||
|
||||
const UpcomingAppointmentItem: React.FC<UpcomingAppointmentItemProps> = ({ appointment }) => (
|
||||
<div className="flex items-center justify-between p-3 bg-accent/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{appointment.doctorName}</p>
|
||||
<p className="text-sm text-muted-foreground">{appointment.specialty}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{appointment.date}</p>
|
||||
<p className="text-sm text-muted-foreground">{appointment.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- Tipos e Dados Estáticos ---
|
||||
|
||||
interface QuickAction {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
variant?: "outline";
|
||||
}
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ href: "/patient/schedule", label: "Agendar Nova Consulta", icon: Plus, variant: "outline" },
|
||||
{ href: "/patient/appointments", label: "Ver Minhas Consultas", icon: Calendar, variant: "outline" },
|
||||
{ href: "/patient/profile", label: "Atualizar Dados", icon: User, variant: "outline" },
|
||||
];
|
||||
|
||||
// --- Componente da Página ---
|
||||
export default function PatientDashboard() {
|
||||
const [statsData, setStatsData] = useState<DashboardStatCardProps[]>([]);
|
||||
const [upcomingAppointments, setUpcomingAppointments] = useState<ApiAppointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const user = await usuariosApi.getCurrentUser();
|
||||
if (!user || !user.id) {
|
||||
throw new Error("Usuário não autenticado.");
|
||||
}
|
||||
|
||||
const [appointmentsResponse, patientResponse] = await Promise.allSettled([
|
||||
agendamentosApi.listByPatient(user.id),
|
||||
pacientesApi.getById(user.id)
|
||||
]);
|
||||
|
||||
let appointments: ApiAppointment[] = [];
|
||||
if (appointmentsResponse.status === 'fulfilled') {
|
||||
appointments = appointmentsResponse.value;
|
||||
// LÓGICA DE FALLBACK PARA AGENDAMENTOS
|
||||
if (appointments.length === 0) {
|
||||
console.warn("Nenhum agendamento encontrado na API real. Buscando do mock...");
|
||||
toast.info("Usando dados de exemplo para os agendamentos.");
|
||||
appointments = await agendamentosApi.getMockAppointments();
|
||||
}
|
||||
} else {
|
||||
console.error("Falha ao buscar agendamentos:", appointmentsResponse.reason);
|
||||
setError("Não foi possível carregar seus agendamentos. Tentando usar dados de exemplo.");
|
||||
appointments = await agendamentosApi.getMockAppointments(); // Fallback em caso de erro
|
||||
}
|
||||
|
||||
const upcoming = appointments
|
||||
.filter(appt => new Date(appt.scheduled_at) > new Date() && appt.status !== 'cancelled')
|
||||
.sort((a, b) => new Date(a.scheduled_at).getTime() - new Date(b.scheduled_at).getTime());
|
||||
|
||||
setUpcomingAppointments(upcoming);
|
||||
|
||||
let patientData: Patient | null = null;
|
||||
if (patientResponse.status === 'fulfilled') {
|
||||
patientData = patientResponse.value;
|
||||
} else {
|
||||
console.warn("Paciente não encontrado na API real. Tentando buscar do mock...", patientResponse.reason);
|
||||
try {
|
||||
patientData = await pacientesApi.getMockPatient();
|
||||
toast.info("Usando dados de exemplo para o perfil do paciente.");
|
||||
} catch (mockError) {
|
||||
console.error("Falha ao buscar dados do mock:", mockError);
|
||||
}
|
||||
}
|
||||
|
||||
const nextAppointment = upcoming[0];
|
||||
const appointmentsThisMonth = appointments.filter(appt => {
|
||||
const apptDate = new Date(appt.scheduled_at);
|
||||
const now = new Date();
|
||||
return apptDate.getMonth() === now.getMonth() && apptDate.getFullYear() === now.getFullYear();
|
||||
});
|
||||
|
||||
let profileCompleteness = 0;
|
||||
let profileDescription = "Dados não encontrados";
|
||||
if (patientData) {
|
||||
const profileFields = ['nome_completo', 'cpf', 'email', 'telefone', 'data_nascimento', 'endereco', 'cidade', 'estado', 'cep', 'convenio'];
|
||||
const filledFields = profileFields.filter(field => patientData[field]).length;
|
||||
profileCompleteness = Math.round((filledFields / profileFields.length) * 100);
|
||||
profileDescription = profileCompleteness === 100 ? "Dados completos" : `${filledFields} de ${profileFields.length} campos preenchidos`;
|
||||
}
|
||||
|
||||
setStatsData([
|
||||
{
|
||||
title: "Próxima Consulta",
|
||||
value: nextAppointment ? new Date(nextAppointment.scheduled_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' }) : "Nenhuma",
|
||||
description: nextAppointment ? `${nextAppointment.doctors?.full_name || 'Médico'} - ${new Date(nextAppointment.scheduled_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}` : "Sem consultas futuras",
|
||||
icon: Calendar
|
||||
},
|
||||
{
|
||||
title: "Consultas Este Mês",
|
||||
value: appointmentsThisMonth.length.toString(),
|
||||
description: `${appointmentsThisMonth.filter(a => a.status === 'completed').length} realizadas`,
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
title: "Perfil",
|
||||
value: `${profileCompleteness}%`,
|
||||
description: profileDescription,
|
||||
icon: User
|
||||
},
|
||||
]);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Erro geral ao carregar dados do dashboard:", err);
|
||||
setError("Não foi possível carregar as informações. Tente novamente mais tarde.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center text-muted-foreground">Carregando dashboard...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-center text-destructive p-4 bg-destructive/10 rounded-md">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Bem-vindo ao seu portal de consultas médicas.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{statsData.map((stat) => (
|
||||
<DashboardStatCard key={stat.title} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ações Rápidas</CardTitle>
|
||||
<CardDescription>Acesse rapidamente as principais funcionalidades.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link key={action.href} href={action.href}>
|
||||
<Button variant={action.variant} className="w-full justify-start bg-transparent">
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximas Consultas</CardTitle>
|
||||
<CardDescription>Suas consultas agendadas para o futuro.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{upcomingAppointments.length > 0 ? (
|
||||
upcomingAppointments.slice(0, 5).map((appointment) => (
|
||||
<UpcomingAppointmentItem key={appointment.id} appointment={{
|
||||
doctorName: appointment.doctors?.full_name || 'Médico a confirmar',
|
||||
specialty: appointment.doctors?.specialty || 'Especialidade',
|
||||
date: new Date(appointment.scheduled_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' }),
|
||||
time: new Date(appointment.scheduled_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
|
||||
}} />
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Você não tem nenhuma consulta agendada.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/patient/layout.tsx
Normal file
75
app/patient/layout.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// Caminho: app/(patient)/layout.tsx (Refatoração Sugerida)
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usuariosApi, User } from "@/services/usuariosApi"; // Assumindo que User é exportado
|
||||
import DashboardLayout, { UserProfile } from "@/components/layout/DashboardLayout";
|
||||
import { dashboardConfig } from "@/config/dashboard.config";
|
||||
|
||||
// --- Contexto para compartilhar dados do usuário autenticado ---
|
||||
interface PatientAuthContextType {
|
||||
user: User | null;
|
||||
userProfile: UserProfile | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const PatientAuthContext = createContext<PatientAuthContextType | undefined>(undefined);
|
||||
|
||||
// Hook customizado para facilitar o acesso ao contexto nas páginas filhas
|
||||
export const usePatientAuth = () => {
|
||||
const context = useContext(PatientAuthContext);
|
||||
if (!context) {
|
||||
throw new Error("usePatientAuth deve ser usado dentro de um PatientLayout");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export default function PatientLayout({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthentication = async () => {
|
||||
try {
|
||||
const userData = await usuariosApi.getCurrentUser();
|
||||
if (!userData) throw new Error("Usuário não autenticado.");
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error("Falha na autenticação para paciente:", error);
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
checkAuthentication();
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Verificando autenticação...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null; // Evita renderizar o layout durante o redirecionamento
|
||||
}
|
||||
|
||||
// A formatação do perfil agora é feita aqui, com os dados já carregados
|
||||
const userProfile = dashboardConfig.patient.getUserProfile(user);
|
||||
const menuItems = dashboardConfig.patient.menuItems;
|
||||
const contextValue = { user, userProfile, isLoading };
|
||||
|
||||
return (
|
||||
<PatientAuthContext.Provider value={contextValue}>
|
||||
<DashboardLayout menuItems={menuItems} userProfile={userProfile}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
</PatientAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
// Caminho: app/patient/login/page.tsx
|
||||
|
||||
import Link from "next/link";
|
||||
import { LoginForm } from "@/components/LoginForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function PatientLoginPage() {
|
||||
// NOTA: Esta página de login específica para pacientes se tornou obsoleta
|
||||
// com a criação da nossa página de login central em /login.
|
||||
// Mantemos este arquivo por enquanto para evitar quebrar outras partes do código,
|
||||
// mas o ideal no futuro seria deletar esta página e redirecionar
|
||||
// /patient/login para /login.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8">
|
||||
<Link href="/" className="inline-flex items-center text-muted-foreground hover:text-primary transition-colors duration-200 font-medium">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
|
||||
{/* Removemos as props desnecessárias (title, description, role, etc.) */}
|
||||
{/* O novo LoginForm é autônomo e não precisa mais delas. */}
|
||||
<LoginForm>
|
||||
{/* Este bloco é passado como 'children' para o LoginForm */}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-muted-foreground">Não tem uma conta? </span>
|
||||
<Link href="/patient/register">
|
||||
<span className="font-semibold text-primary hover:underline cursor-pointer">
|
||||
Crie uma agora
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</LoginForm>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">Problemas para acessar? Entre em contato conosco</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,139 +1,244 @@
|
||||
"use client"
|
||||
// Caminho: app/(patient)/schedule/page.tsx (Completo e Corrigido)
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Calendar, Clock, User } from "lucide-react"
|
||||
import PatientLayout from "@/components/patient-layout"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { doctorsService } from "services/doctorsApi.mjs"
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format, getDay } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
// A importação do PatientLayout foi REMOVIDA
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar as CalendarIcon, Clock, User as UserIcon } from "lucide-react";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Doctor {
|
||||
id: string
|
||||
full_name: string
|
||||
specialty: string
|
||||
phone_mobile: string
|
||||
import { usuariosApi, User } from "@/services/usuariosApi";
|
||||
import { medicosApi, Doctor } from "@/services/medicosApi";
|
||||
import { agendamentosApi } from "@/services/agendamentosApi";
|
||||
import { disponibilidadeApi, DoctorAvailability, DoctorException } from "@/services/disponibilidadeApi";
|
||||
|
||||
interface AvailabilityRules {
|
||||
weekly: DoctorAvailability[];
|
||||
exceptions: DoctorException[];
|
||||
}
|
||||
|
||||
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments"
|
||||
|
||||
export default function ScheduleAppointment() {
|
||||
const [selectedDoctor, setSelectedDoctor] = useState("")
|
||||
const [selectedDate, setSelectedDate] = useState("")
|
||||
const [selectedTime, setSelectedTime] = useState("")
|
||||
const [notes, setNotes] = useState("")
|
||||
const router = useRouter();
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [availableSlots, setAvailableSlots] = useState<string[]>([]);
|
||||
|
||||
const [availabilityRules, setAvailabilityRules] = useState<AvailabilityRules | null>(null);
|
||||
const [isAvailabilityLoading, setIsAvailabilityLoading] = useState(false);
|
||||
|
||||
// novos campos
|
||||
const [tipoConsulta, setTipoConsulta] = useState("presencial")
|
||||
const [duracao, setDuracao] = useState("30")
|
||||
const [convenio, setConvenio] = useState("")
|
||||
const [queixa, setQueixa] = useState("")
|
||||
const [obsPaciente, setObsPaciente] = useState("")
|
||||
const [obsInternas, setObsInternas] = useState("")
|
||||
const [formData, setFormData] = useState<{
|
||||
doctorId: string;
|
||||
date: Date | undefined;
|
||||
time: string;
|
||||
appointmentType: string;
|
||||
duration: string;
|
||||
reason: string;
|
||||
}>({
|
||||
doctorId: "",
|
||||
date: undefined,
|
||||
time: "",
|
||||
appointmentType: "presencial",
|
||||
duration: "30",
|
||||
reason: "",
|
||||
});
|
||||
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDoctors = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data: Doctor[] = await doctorsService.list()
|
||||
setDoctors(data || [])
|
||||
} catch (e: any) {
|
||||
console.error("Erro ao carregar lista de médicos:", e)
|
||||
setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API.")
|
||||
setDoctors([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSlotsLoading, setIsSlotsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDoctors()
|
||||
}, [fetchDoctors])
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const currentUser = await usuariosApi.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
|
||||
const availableTimes = [
|
||||
"08:00",
|
||||
"08:30",
|
||||
"09:00",
|
||||
"09:30",
|
||||
"10:00",
|
||||
"10:30",
|
||||
"14:00",
|
||||
"14:30",
|
||||
"15:00",
|
||||
"15:30",
|
||||
"16:00",
|
||||
"16:30",
|
||||
]
|
||||
let activeDoctors = await medicosApi.list({ active: true });
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (activeDoctors.length === 0) {
|
||||
console.warn("Nenhum médico ativo encontrado. Buscando do mock...");
|
||||
toast.info("Usando dados de exemplo para a lista de médicos.");
|
||||
activeDoctors = await medicosApi.getMockDoctors();
|
||||
}
|
||||
|
||||
setDoctors(activeDoctors);
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar dados iniciais:", e);
|
||||
setError("Não foi possível carregar os dados necessários para o agendamento.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const doctorDetails = doctors.find((d) => d.id === selectedDoctor)
|
||||
const patientDetails = {
|
||||
id: "P001",
|
||||
full_name: "Paciente Exemplo Único",
|
||||
location: "Clínica Geral",
|
||||
phone: "(11) 98765-4321",
|
||||
useEffect(() => {
|
||||
const fetchDoctorAvailability = async () => {
|
||||
if (!formData.doctorId) {
|
||||
setAvailabilityRules(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.doctorId.startsWith("mock-")) {
|
||||
setAvailabilityRules({ weekly: [], exceptions: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAvailabilityLoading(true);
|
||||
try {
|
||||
const [weekly, exceptions] = await Promise.all([
|
||||
disponibilidadeApi.list({ doctor_id: formData.doctorId, active: true }),
|
||||
disponibilidadeApi.listExceptions({ doctor_id: formData.doctorId }),
|
||||
]);
|
||||
setAvailabilityRules({ weekly, exceptions });
|
||||
} catch (err) {
|
||||
console.error("Erro ao buscar disponibilidade do médico:", err);
|
||||
toast.error("Não foi possível carregar a agenda do médico.");
|
||||
setAvailabilityRules(null);
|
||||
} finally {
|
||||
setIsAvailabilityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDoctorAvailability();
|
||||
}, [formData.doctorId]);
|
||||
|
||||
const fetchAvailableSlots = (doctorId: string, date: Date | undefined) => {
|
||||
if (!doctorId || !date) return;
|
||||
|
||||
setIsSlotsLoading(true);
|
||||
setAvailableSlots([]);
|
||||
|
||||
if (doctorId.startsWith("mock-")) {
|
||||
setTimeout(() => {
|
||||
const mockSlots = ["09:00", "10:00", "11:00", "14:00", "15:00"];
|
||||
setAvailableSlots(mockSlots);
|
||||
setIsSlotsLoading(false);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDate = format(date, "yyyy-MM-dd");
|
||||
|
||||
agendamentosApi.getAvailableSlots(doctorId, formattedDate)
|
||||
.then(response => {
|
||||
const slots = response.slots.filter(s => s.available).map(s => s.time);
|
||||
setAvailableSlots(slots);
|
||||
})
|
||||
.catch(() => toast.error("Não foi possível buscar horários para esta data."))
|
||||
.finally(() => setIsSlotsLoading(false));
|
||||
};
|
||||
|
||||
const handleSelectChange = (name: keyof typeof formData) => (value: string | Date | undefined) => {
|
||||
const newFormData = { ...formData, [name]: value } as any;
|
||||
if (name === 'doctorId') {
|
||||
newFormData.date = undefined;
|
||||
newFormData.time = "";
|
||||
}
|
||||
if (name === 'date') {
|
||||
newFormData.time = "";
|
||||
fetchAvailableSlots(newFormData.doctorId, newFormData.date);
|
||||
}
|
||||
setFormData(newFormData);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { id, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user?.id || !formData.date) {
|
||||
toast.error("Erro de autenticação ou data inválida.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.doctorId.startsWith("mock-")) {
|
||||
toast.success("Simulação de agendamento com médico de exemplo concluída!");
|
||||
router.push("/patient/appointments");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!patientDetails || !doctorDetails) {
|
||||
alert("Erro: Selecione o médico ou dados do paciente indisponíveis.")
|
||||
return
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const newScheduledAt = new Date(`${format(formData.date, "yyyy-MM-dd")}T${formData.time}:00Z`).toISOString();
|
||||
|
||||
await agendamentosApi.create({
|
||||
doctor_id: formData.doctorId,
|
||||
patient_id: user.id,
|
||||
scheduled_at: newScheduledAt,
|
||||
duration_minutes: parseInt(formData.duration, 10),
|
||||
appointment_type: formData.appointmentType as 'presencial' | 'telemedicina',
|
||||
status: "requested",
|
||||
created_by: user.id,
|
||||
notes: formData.reason,
|
||||
});
|
||||
|
||||
toast.success("Consulta agendada com sucesso!");
|
||||
router.push("/patient/appointments");
|
||||
} catch (error) {
|
||||
console.error("Erro ao agendar consulta:", error);
|
||||
toast.error("Falha ao agendar a consulta. Tente novamente.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDateDisabled = (date: Date): boolean => {
|
||||
if (date < new Date(new Date().setDate(new Date().getDate() - 1))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newAppointment = {
|
||||
id: new Date().getTime(),
|
||||
patientName: patientDetails.full_name,
|
||||
doctor: doctorDetails.full_name,
|
||||
specialty: doctorDetails.specialty,
|
||||
date: selectedDate,
|
||||
time: selectedTime,
|
||||
tipoConsulta,
|
||||
duracao,
|
||||
convenio,
|
||||
queixa,
|
||||
obsPaciente,
|
||||
obsInternas,
|
||||
notes,
|
||||
status: "agendada",
|
||||
phone: patientDetails.phone,
|
||||
if (!availabilityRules) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY)
|
||||
const currentAppointments = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : []
|
||||
const updatedAppointments = [...currentAppointments, newAppointment]
|
||||
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments))
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
const dayOfWeek = getDay(date);
|
||||
|
||||
alert(`Consulta com ${doctorDetails.full_name} agendada com sucesso!`)
|
||||
const fullDayBlock = availabilityRules.exceptions.find(
|
||||
ex => ex.date === dateString && ex.kind === 'bloqueio' && !ex.start_time
|
||||
);
|
||||
if (fullDayBlock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// resetar campos
|
||||
setSelectedDoctor("")
|
||||
setSelectedDate("")
|
||||
setSelectedTime("")
|
||||
setNotes("")
|
||||
setTipoConsulta("presencial")
|
||||
setDuracao("30")
|
||||
setConvenio("")
|
||||
setQueixa("")
|
||||
setObsPaciente("")
|
||||
setObsInternas("")
|
||||
}
|
||||
const worksOnThisDay = availabilityRules.weekly.some(
|
||||
avail => avail.weekday === dayOfWeek
|
||||
);
|
||||
|
||||
return !worksOnThisDay;
|
||||
};
|
||||
|
||||
const selectedDoctorDetails = doctors.find((d) => d.id === formData.doctorId);
|
||||
const isFormInvalid = !formData.doctorId || !formData.date || !formData.time || isSubmitting;
|
||||
|
||||
return (
|
||||
<PatientLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1>
|
||||
<p className="text-gray-600">Escolha o médico, data e horário para sua consulta</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Agendar Consulta</h1>
|
||||
<p className="text-muted-foreground">Escolha o médico, data e horário para sua consulta</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Carregando...</p>
|
||||
) : error ? (
|
||||
<p className="text-destructive">{error}</p>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
@ -143,203 +248,112 @@ export default function ScheduleAppointment() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
|
||||
{/* Médico */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="doctor">Médico</Label>
|
||||
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um médico" />
|
||||
</SelectTrigger>
|
||||
<Label htmlFor="doctorId">Médico</Label>
|
||||
<Select value={formData.doctorId} onValueChange={handleSelectChange('doctorId')}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecione um médico" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Carregando médicos...
|
||||
</SelectItem>
|
||||
) : error ? (
|
||||
<SelectItem value="error" disabled>
|
||||
Erro ao carregar
|
||||
</SelectItem>
|
||||
) : (
|
||||
{doctors.length > 0 ? (
|
||||
doctors.map((doctor) => (
|
||||
<SelectItem key={doctor.id} value={doctor.id}>
|
||||
{doctor.full_name} - {doctor.specialty}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2 text-center text-sm text-muted-foreground">Nenhum médico disponível</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Data e horário */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Data</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn("w-full justify-start text-left font-normal", !formData.date && "text-muted-foreground")}
|
||||
disabled={!formData.doctorId || isAvailabilityLoading}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{isAvailabilityLoading ? "Carregando agenda..." : formData.date ? format(formData.date, "PPP", { locale: ptBR }) : <span>Escolha uma data</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.date}
|
||||
onSelect={handleSelectChange('date')}
|
||||
initialFocus
|
||||
disabled={isDateDisabled}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time">Horário</Label>
|
||||
<Select value={selectedTime} onValueChange={setSelectedTime}>
|
||||
<Select value={formData.time} onValueChange={handleSelectChange('time')} disabled={!formData.date || isSlotsLoading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um horário" />
|
||||
<SelectValue placeholder={isSlotsLoading ? "Carregando..." : "Selecione um horário"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTimes.map((time) => (
|
||||
<SelectItem key={time} value={time}>
|
||||
{time}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isSlotsLoading ? (
|
||||
<div className="p-2 text-center text-sm text-muted-foreground">Carregando horários...</div>
|
||||
) : availableSlots.length > 0 ? (
|
||||
availableSlots.map((time) => <SelectItem key={time} value={time}>{time}</SelectItem>)
|
||||
) : (
|
||||
<div className="p-2 text-center text-sm text-muted-foreground">
|
||||
{formData.date ? "Nenhum horário disponível" : "Selecione uma data"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tipo e Duração */}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tipoConsulta">Tipo de Consulta</Label>
|
||||
<Select value={tipoConsulta} onValueChange={setTipoConsulta}>
|
||||
<SelectTrigger id="tipoConsulta">
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<Label htmlFor="appointmentType">Tipo de Consulta</Label>
|
||||
<Select value={formData.appointmentType} onValueChange={handleSelectChange('appointmentType')}>
|
||||
<SelectTrigger id="appointmentType"><SelectValue placeholder="Selecione o tipo" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="presencial">Presencial</SelectItem>
|
||||
<SelectItem value="online">Telemedicina</SelectItem>
|
||||
<SelectItem value="telemedicina">Telemedicina</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duracao">Duração (minutos)</Label>
|
||||
<Input
|
||||
id="duracao"
|
||||
type="number"
|
||||
min={10}
|
||||
max={120}
|
||||
value={duracao}
|
||||
onChange={(e) => setDuracao(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="duration">Duração (minutos)</Label>
|
||||
<Input id="duration" type="number" min={10} max={120} value={formData.duration} onChange={handleInputChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Convênio */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="convenio">Convênio (opcional)</Label>
|
||||
<Input
|
||||
id="convenio"
|
||||
placeholder="Nome do convênio do paciente"
|
||||
value={convenio}
|
||||
onChange={(e) => setConvenio(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="reason">Queixa Principal / Observações (opcional)</Label>
|
||||
<Textarea id="reason" placeholder="Descreva brevemente o motivo da consulta ou observações importantes..." value={formData.reason} onChange={handleInputChange} rows={3} />
|
||||
</div>
|
||||
|
||||
{/* Queixa Principal */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="queixa">Queixa Principal (opcional)</Label>
|
||||
<Textarea
|
||||
id="queixa"
|
||||
placeholder="Descreva brevemente o motivo da consulta..."
|
||||
value={queixa}
|
||||
onChange={(e) => setQueixa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Observações do Paciente */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="obsPaciente">Observações do Paciente (opcional)</Label>
|
||||
<Textarea
|
||||
id="obsPaciente"
|
||||
placeholder="Anotações relevantes informadas pelo paciente..."
|
||||
value={obsPaciente}
|
||||
onChange={(e) => setObsPaciente(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Observações Internas */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="obsInternas">Observações Internas (opcional)</Label>
|
||||
<Textarea
|
||||
id="obsInternas"
|
||||
placeholder="Anotações para a equipe da clínica..."
|
||||
value={obsInternas}
|
||||
onChange={(e) => setObsInternas(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Observações gerais */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Observações gerais (opcional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Descreva brevemente o motivo da consulta ou observações importantes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Botão */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!selectedDoctor || !selectedDate || !selectedTime}
|
||||
>
|
||||
Agendar Consulta
|
||||
<Button type="submit" className="w-full" disabled={isFormInvalid}>
|
||||
{isSubmitting ? "Agendando..." : "Agendar Consulta"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resumo */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="mr-2 h-5 w-5" />
|
||||
Resumo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle className="flex items-center"><CalendarIcon className="mr-2 h-5 w-5" /> Resumo</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedDoctor && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">
|
||||
{doctors.find((d) => d.id === selectedDoctor)?.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDate && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">
|
||||
{new Date(selectedDate).toLocaleDateString("pt-BR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTime && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">{selectedTime}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedDoctorDetails && <div className="flex items-center space-x-2"><UserIcon className="h-4 w-4 text-muted-foreground" /><span className="text-sm">{selectedDoctorDetails.full_name}</span></div>}
|
||||
{formData.date && <div className="flex items-center space-x-2"><CalendarIcon className="h-4 w-4 text-muted-foreground" /><span className="text-sm">{format(formData.date, "PPP", { locale: ptBR })}</span></div>}
|
||||
{formData.time && <div className="flex items-center space-x-2"><Clock className="h-4 w-4 text-muted-foreground" /><span className="text-sm">{formData.time}</span></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações Importantes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-gray-600 space-y-2">
|
||||
<CardHeader><CardTitle>Informações Importantes</CardTitle></CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>• Chegue com 15 minutos de antecedência</p>
|
||||
<p>• Traga documento com foto</p>
|
||||
<p>• Traga carteirinha do convênio</p>
|
||||
@ -348,7 +362,7 @@ export default function ScheduleAppointment() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PatientLayout>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,9 +12,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Calendar, Clock, MapPin, Phone, User, Trash2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { appointmentsService } from "@/services/appointmentsApi.mjs";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { doctorsService } from "@/services/doctorsApi.mjs";
|
||||
import { appointmentsService } from "@/services/agendamentosApi";
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
import { doctorsService } from "@/services/medicosApi";
|
||||
|
||||
export default function SecretaryAppointments() {
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
|
||||
@ -12,8 +12,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, User, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { appointmentsService } from "@/services/appointmentsApi.mjs";
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
import { appointmentsService } from "@/services/agendamentosApi";
|
||||
|
||||
export default function SecretaryDashboard() {
|
||||
// Estados
|
||||
|
||||
73
app/secretary/layout.tsx
Normal file
73
app/secretary/layout.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
// Caminho: app/(secretary)/layout.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Nossas importações centralizadas
|
||||
import { usuariosApi } from "@/services/usuariosApi";
|
||||
import DashboardLayout, { UserProfile } from "@/components/layout/DashboardLayout";
|
||||
import { dashboardConfig } from "@/config/dashboard.config";
|
||||
|
||||
interface SecretaryLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SecretaryLayout({ children }: SecretaryLayoutProps) {
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthentication = async () => {
|
||||
try {
|
||||
// 1. Busca o usuário logado via API
|
||||
const userData = await usuariosApi.getCurrentUser();
|
||||
|
||||
// 2. Pega a configuração específica da "secretária"
|
||||
const config = dashboardConfig.secretary;
|
||||
if (!config) {
|
||||
throw new Error("Configuração para o perfil 'secretary' não encontrada.");
|
||||
}
|
||||
|
||||
// 3. Formata os dados para o perfil
|
||||
setUserProfile(config.getUserProfile(userData));
|
||||
|
||||
} catch (error) {
|
||||
// 4. Se falhar, redireciona para o login
|
||||
console.error("Falha na autenticação para secretária:", error);
|
||||
router.push("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuthentication();
|
||||
}, [router]);
|
||||
|
||||
// Enquanto a verificação estiver em andamento, mostra uma tela de carregamento
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Verificando autenticação...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não tiver perfil (redirect em andamento), não renderiza nada para evitar erros
|
||||
if (!userProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pega os itens de menu da configuração
|
||||
const menuItems = dashboardConfig.secretary.menuItems;
|
||||
|
||||
// Renderiza o layout genérico com as props corretas
|
||||
return (
|
||||
<DashboardLayout menuItems={menuItems} userProfile={userProfile}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
// Caminho: app/(secretary)/login/page.tsx
|
||||
|
||||
import { LoginForm } from "@/components/LoginForm";
|
||||
import Link from "next/link"; // Adicionado para o link de "Voltar"
|
||||
|
||||
export default function SecretaryLoginPage() {
|
||||
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
|
||||
// O ideal no futuro é deletar esta página e redirecionar os usuários.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Área da Secretária</h1>
|
||||
<p className="text-muted-foreground mb-8">Acesse o sistema de gerenciamento</p>
|
||||
|
||||
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
|
||||
{/* Chamando o LoginForm unificado sem props desnecessárias */}
|
||||
<LoginForm>
|
||||
{/* Adicionamos um link de "Voltar" como filho (children) */}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/">
|
||||
<span className="font-semibold text-primary hover:underline cursor-pointer">
|
||||
Voltar à página inicial
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</LoginForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,7 @@ import { ArrowLeft, Save, Trash2, Paperclip, Upload } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import SecretaryLayout from "@/components/secretary-layout";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
import { json } from "stream/consumers";
|
||||
|
||||
export default function EditarPacientePage() {
|
||||
|
||||
@ -15,7 +15,7 @@ import { Upload, Plus, X, ChevronDown } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import SecretaryLayout from "@/components/secretary-layout";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
|
||||
export default function NovoPacientePage() {
|
||||
const [anexosOpen, setAnexosOpen] = useState(false);
|
||||
|
||||
@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Plus, Edit, Trash2, Eye, Calendar, Filter } from "lucide-react";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import SecretaryLayout from "@/components/secretary-layout";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
|
||||
export default function PacientesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Caminho: secretary/appointments/page.tsx (Refatorado)
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
@ -11,19 +12,18 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Calendar, Clock, User } from "lucide-react";
|
||||
import { patientsService } from "@/services/patientsApi.mjs";
|
||||
import { doctorsService } from "@/services/doctorsApi.mjs";
|
||||
import { appointmentsService } from "@/services/appointmentsApi.mjs";
|
||||
import { usersService } from "@/services/usersApi.mjs"; // 1. IMPORTAR O SERVIÇO DE USUÁRIOS
|
||||
import { patientsService } from "@/services/pacientesApi";
|
||||
import { doctorsService } from "@/services/medicosApi";
|
||||
import { appointmentsService } from "@/services/agendamentosApi";
|
||||
import { usuariosService } from "@/services/usuariosApi"; // Alterado
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ScheduleAppointment() {
|
||||
const router = useRouter();
|
||||
const [patients, setPatients] = useState<any[]>([]);
|
||||
const [doctors, setDoctors] = useState<any[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null); // 2. NOVO ESTADO PARA O ID DO USUÁRIO
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
// Estados do formulário
|
||||
const [selectedPatient, setSelectedPatient] = useState("");
|
||||
const [selectedDoctor, setSelectedDoctor] = useState("");
|
||||
const [selectedDate, setSelectedDate] = useState("");
|
||||
@ -35,32 +35,25 @@ export default function ScheduleAppointment() {
|
||||
const [internalNotes, setInternalNotes] = useState("");
|
||||
const [insuranceProvider, setInsuranceProvider] = useState("");
|
||||
|
||||
const availableTimes = [
|
||||
"08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
|
||||
"14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30"
|
||||
];
|
||||
const availableTimes = [ "08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30" ];
|
||||
|
||||
// Efeito para carregar todos os dados iniciais (pacientes, médicos e usuário atual)
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
// Busca tudo em paralelo para melhor performance
|
||||
const [patientList, doctorList, currentUser] = await Promise.all([
|
||||
patientsService.list(),
|
||||
doctorsService.list(),
|
||||
usersService.summary_data() // 3. CHAMADA PARA BUSCAR O USUÁRIO
|
||||
usuariosService.getCurrentUser() // Alterado
|
||||
]);
|
||||
|
||||
setPatients(patientList);
|
||||
setDoctors(doctorList);
|
||||
|
||||
if (currentUser && currentUser.id) {
|
||||
setCurrentUserId(currentUser.id); // Armazena o ID do usuário no estado
|
||||
console.log("Usuário logado identificado:", currentUser.id);
|
||||
setCurrentUserId(currentUser.id);
|
||||
} else {
|
||||
toast.error("Não foi possível identificar o usuário logado. O agendamento pode falhar.");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Falha ao buscar dados iniciais:", error);
|
||||
toast.error("Não foi possível carregar os dados necessários para a página.");
|
||||
@ -71,21 +64,16 @@ export default function ScheduleAppointment() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 4. ADICIONAR VALIDAÇÃO PARA O ID DO USUÁRIO
|
||||
if (!currentUserId) {
|
||||
toast.error("Sessão de usuário inválida. Por favor, faça login novamente.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime) {
|
||||
toast.error("Paciente, médico, data e horário são obrigatórios.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduledAt = new Date(`${selectedDate}T${selectedTime}:00Z`).toISOString();
|
||||
|
||||
const newAppointmentData = {
|
||||
patient_id: selectedPatient,
|
||||
doctor_id: selectedDoctor,
|
||||
@ -97,13 +85,9 @@ export default function ScheduleAppointment() {
|
||||
patient_notes: patientNotes || null,
|
||||
notes: internalNotes || null,
|
||||
insurance_provider: insuranceProvider || null,
|
||||
created_by: currentUserId, // 5. INCLUIR O ID DO USUÁRIO NO OBJETO
|
||||
created_by: currentUserId,
|
||||
};
|
||||
|
||||
console.log("Enviando dados do agendamento:", newAppointmentData); // Log para depuração
|
||||
|
||||
await appointmentsService.create(newAppointmentData);
|
||||
|
||||
toast.success("Consulta agendada com sucesso!");
|
||||
router.push("/secretary/appointments");
|
||||
} catch (error) {
|
||||
@ -114,91 +98,35 @@ export default function ScheduleAppointment() {
|
||||
|
||||
return (
|
||||
<SecretaryLayout>
|
||||
{/* O JSX restante permanece exatamente o mesmo */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1>
|
||||
<p className="text-gray-600">Preencha os detalhes para criar um novo agendamento</p>
|
||||
</div>
|
||||
|
||||
<div><h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1><p className="text-gray-600">Preencha os detalhes para criar um novo agendamento</p></div>
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dados da Consulta</CardTitle>
|
||||
<CardDescription>Preencha as informações para agendar a consulta</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Dados da Consulta</CardTitle><CardDescription>Preencha as informações para agendar a consulta</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* O restante do formulário permanece exatamente o mesmo */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="patient">Paciente</Label>
|
||||
<Select value={selectedPatient} onValueChange={setSelectedPatient}><SelectTrigger><SelectValue placeholder="Selecione um paciente" /></SelectTrigger><SelectContent>{patients.map((p) => (<SelectItem key={p.id} value={p.id}>{p.full_name}</SelectItem>))}</SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="doctor">Médico</Label>
|
||||
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}><SelectTrigger><SelectValue placeholder="Selecione um médico" /></SelectTrigger><SelectContent>{doctors.map((d) => (<SelectItem key={d.id} value={d.id}>{d.full_name} - {d.specialty}</SelectItem>))}</SelectContent></Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2"><Label htmlFor="patient">Paciente</Label><Select value={selectedPatient} onValueChange={setSelectedPatient}><SelectTrigger><SelectValue placeholder="Selecione um paciente" /></SelectTrigger><SelectContent>{patients.map((p) => (<SelectItem key={p.id} value={p.id}>{p.full_name}</SelectItem>))}</SelectContent></Select></div>
|
||||
<div className="space-y-2"><Label htmlFor="doctor">Médico</Label><Select value={selectedDoctor} onValueChange={setSelectedDoctor}><SelectTrigger><SelectValue placeholder="Selecione um médico" /></SelectTrigger><SelectContent>{doctors.map((d) => (<SelectItem key={d.id} value={d.id}>{d.full_name} - {d.specialty}</SelectItem>))}</SelectContent></Select></div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Data</Label>
|
||||
<Input id="date" type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} min={new Date().toISOString().split("T")[0]} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time">Horário</Label>
|
||||
<Select value={selectedTime} onValueChange={setSelectedTime}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um horário" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTimes.map((time) => (
|
||||
<SelectItem key={time} value={time}>
|
||||
{time}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2"><Label htmlFor="date">Data</Label><Input id="date" type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} min={new Date().toISOString().split("T")[0]} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="time">Horário</Label><Select value={selectedTime} onValueChange={setSelectedTime}><SelectTrigger><SelectValue placeholder="Selecione um horário" /></SelectTrigger><SelectContent>{availableTimes.map((time) => (<SelectItem key={time} value={time}>{time}</SelectItem>))}</SelectContent></Select></div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appointmentType">Tipo de Consulta</Label>
|
||||
<Select value={appointmentType} onValueChange={setAppointmentType}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="presencial">Presencial</SelectItem><SelectItem value="telemedicina">Telemedicina</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Duração (minutos)</Label>
|
||||
<Input id="duration" type="number" value={durationMinutes} onChange={(e) => setDurationMinutes(e.target.value)} placeholder="Ex: 30" />
|
||||
</div>
|
||||
<div className="space-y-2"><Label htmlFor="appointmentType">Tipo de Consulta</Label><Select value={appointmentType} onValueChange={setAppointmentType}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="presencial">Presencial</SelectItem><SelectItem value="telemedicina">Telemedicina</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-2"><Label htmlFor="duration">Duração (minutos)</Label><Input id="duration" type="number" value={durationMinutes} onChange={(e) => setDurationMinutes(e.target.value)} placeholder="Ex: 30" /></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="insurance">Convênio (opcional)</Label>
|
||||
<Input id="insurance" placeholder="Nome do convênio do paciente" value={insuranceProvider} onChange={(e) => setInsuranceProvider(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="chiefComplaint">Queixa Principal (opcional)</Label>
|
||||
<Textarea id="chiefComplaint" placeholder="Descreva brevemente o motivo da consulta..." value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="patientNotes">Observações do Paciente (opcional)</Label>
|
||||
<Textarea id="patientNotes" placeholder="Anotações relevantes informadas pelo paciente..." value={patientNotes} onChange={(e) => setPatientNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="internalNotes">Observações Internas (opcional)</Label>
|
||||
<Textarea id="internalNotes" placeholder="Anotações para a equipe da clínica..." value={internalNotes} onChange={(e) => setInternalNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime || !currentUserId}>
|
||||
Agendar Consulta
|
||||
</Button>
|
||||
<div className="space-y-2"><Label htmlFor="insurance">Convênio (opcional)</Label><Input id="insurance" placeholder="Nome do convênio do paciente" value={insuranceProvider} onChange={(e) => setInsuranceProvider(e.target.value)} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="chiefComplaint">Queixa Principal (opcional)</Label><Textarea id="chiefComplaint" placeholder="Descreva brevemente o motivo da consulta..." value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} rows={2} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="patientNotes">Observações do Paciente (opcional)</Label><Textarea id="patientNotes" placeholder="Anotações relevantes informadas pelo paciente..." value={patientNotes} onChange={(e) => setPatientNotes(e.target.value)} rows={2} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="internalNotes">Observações Internas (opcional)</Label><Textarea id="internalNotes" placeholder="Anotações para a equipe da clínica..." value={internalNotes} onChange={(e) => setInternalNotes(e.target.value)} rows={2} /></div>
|
||||
<Button type="submit" className="w-full" disabled={!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime || !currentUserId}>Agendar Consulta</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Card de Resumo e Informações Importantes */}
|
||||
</div>
|
||||
<div className="space-y-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</SecretaryLayout>
|
||||
|
||||
@ -4,24 +4,19 @@
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import Cookies from "js-cookie"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Nossos serviços de API centralizados
|
||||
import { loginWithEmailAndPassword, api } from "@/services/api.mjs";
|
||||
|
||||
import { autenticacaoApi } from '@/services/autenticacaoApi';
|
||||
import api from '@/services/api';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
||||
import { Eye, EyeOff, Mail, Lock, Loader2, UserCheck, Stethoscope, IdCard, Receipt } from "lucide-react";
|
||||
import { Eye, EyeOff, Mail, Lock, Loader2 } from "lucide-react";
|
||||
|
||||
interface LoginFormProps {
|
||||
children?: React.ReactNode
|
||||
@ -39,120 +34,182 @@ export function LoginForm({ children }: LoginFormProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
// ==================================================================
|
||||
// LÓGICA DE LOGIN INTELIGENTE E CENTRALIZADA
|
||||
// ==================================================================
|
||||
// --- ESTADOS PARA CONTROLE DE MÚLTIPLOS PERFIS ---
|
||||
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||
const [authenticatedUser, setAuthenticatedUser] = useState<any>(null);
|
||||
|
||||
/**
|
||||
* Finaliza o login com o perfil de dashboard escolhido e redireciona.
|
||||
* Esta função agora recebe o nome do "dashboard" (ex: 'manager', 'doctor') e não o 'role' da API.
|
||||
*/
|
||||
const handleRoleSelection = (selectedDashboardRole: string) => {
|
||||
const user = authenticatedUser;
|
||||
if (!user) {
|
||||
toast({ title: "Erro de Sessão", description: "Não foi possível encontrar os dados do usuário. Tente novamente."});
|
||||
setUserRoles([]); // Volta para a tela de login
|
||||
return;
|
||||
}
|
||||
|
||||
// Salva o perfil escolhido para uso futuro na aplicação
|
||||
const completeUserInfo = { ...user, user_metadata: { ...user.user_metadata, role: selectedDashboardRole } };
|
||||
localStorage.setItem('user_info', JSON.stringify(completeUserInfo));
|
||||
|
||||
let redirectPath = "";
|
||||
// O switch agora lida com os nomes dos dashboards, não com os roles da API
|
||||
switch (selectedDashboardRole) {
|
||||
case "manager": redirectPath = "/manager/home"; break;
|
||||
case "doctor": redirectPath = "/doctor/medicos"; break;
|
||||
case "secretary": redirectPath = "/secretary/pacientes"; break;
|
||||
case "patient": redirectPath = "/patient/dashboard"; break;
|
||||
case "finance": redirectPath = "/finance/home"; break;
|
||||
}
|
||||
|
||||
if (redirectPath) {
|
||||
toast({ title: `Entrando como ${selectedDashboardRole}...` });
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
toast({ title: "Erro", description: "Perfil selecionado inválido."});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lida com a submissão do formulário, busca os perfis e decide o próximo passo.
|
||||
* Contém a nova lógica para os casos de 'admin' e 'gestor'.
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user_info");
|
||||
Cookies.remove("access_token");
|
||||
|
||||
try {
|
||||
const authData = await loginWithEmailAndPassword(form.email, form.password);
|
||||
const user = authData.user;
|
||||
const authResponse = await autenticacaoApi.loginWithEmailAndPassword(form.email, form.password);
|
||||
const { user, access_token: accessToken } = authResponse;
|
||||
|
||||
if (!user || !user.id) {
|
||||
throw new Error("Resposta de autenticação inválida: ID do usuário não encontrado.");
|
||||
throw new Error("Resposta de autenticação inválida.");
|
||||
}
|
||||
|
||||
const rolesData = await api.get(`/rest/v1/user_roles?user_id=eq.${user.id}&select=role`);
|
||||
// Armazena o token para as próximas requisições autenticadas
|
||||
Cookies.set("access_token", accessToken, { expires: 1 });
|
||||
setAuthenticatedUser(user); // Armazena o usuário para uso posterior
|
||||
|
||||
const { data: rolesData } = await api.get(`/rest/v1/user_roles?user_id=eq.${user.id}&select=role`);
|
||||
|
||||
if (!rolesData || rolesData.length === 0) {
|
||||
throw new Error("Login bem-sucedido, mas nenhum perfil de acesso foi encontrado para este usuário.");
|
||||
throw new Error("Nenhum perfil de acesso foi encontrado para este usuário.");
|
||||
}
|
||||
|
||||
const userRole = rolesData[0].role;
|
||||
const completeUserInfo = { ...user, user_metadata: { ...user.user_metadata, role: userRole } };
|
||||
localStorage.setItem('user_info', JSON.stringify(completeUserInfo));
|
||||
const rolesFromApi: string[] = rolesData.map((r: any) => r.role);
|
||||
|
||||
let redirectPath = "";
|
||||
switch (userRole) {
|
||||
case "admin":
|
||||
case "manager": redirectPath = "/manager/home"; break;
|
||||
case "medico": redirectPath = "/doctor/medicos"; break;
|
||||
case "secretary": redirectPath = "/secretary/pacientes"; break;
|
||||
case "patient": redirectPath = "/patient/dashboard"; break;
|
||||
case "finance": redirectPath = "/finance/home"; break;
|
||||
// --- NOVA LÓGICA DE DECISÃO ---
|
||||
|
||||
// Caso 1: Usuário é ADMIN, mostra todos os perfis de dashboard possíveis.
|
||||
if (rolesFromApi.includes('admin')) {
|
||||
setUserRoles(["manager", "doctor", "secretary", "patient", "finance"]);
|
||||
return; // Para a execução para mostrar a tela de seleção
|
||||
}
|
||||
|
||||
if (!redirectPath) {
|
||||
throw new Error(`O perfil de acesso '${userRole}' não é válido para login. Contate o suporte.`);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Login bem-sucedido!",
|
||||
description: `Bem-vindo(a)! Redirecionando...`,
|
||||
// Mapeia os roles da API para os perfis de dashboard
|
||||
const displayRoles = new Set<string>();
|
||||
rolesFromApi.forEach(role => {
|
||||
switch (role) {
|
||||
case 'gestor':
|
||||
displayRoles.add('manager');
|
||||
displayRoles.add('finance');
|
||||
break;
|
||||
case 'medico':
|
||||
displayRoles.add('doctor');
|
||||
break;
|
||||
case 'secretaria':
|
||||
displayRoles.add('secretary');
|
||||
break;
|
||||
case 'user': // Mapeamento de 'user' para 'patient'
|
||||
displayRoles.add('patient');
|
||||
break;
|
||||
// 'admin' já foi tratado, outros roles podem ser adicionados aqui
|
||||
}
|
||||
});
|
||||
|
||||
router.push(redirectPath);
|
||||
|
||||
const finalRoles = Array.from(displayRoles);
|
||||
|
||||
// Caso 2: Após o mapeamento, se resultar em apenas um perfil, redireciona direto.
|
||||
if (finalRoles.length === 1) {
|
||||
handleRoleSelection(finalRoles[0]);
|
||||
}
|
||||
// Caso 3: Se resultar em múltiplos perfis (ex: 'gestor' sozinho ou combinado), mostra a seleção.
|
||||
else {
|
||||
setUserRoles(finalRoles);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user_info");
|
||||
Cookies.remove("access_token");
|
||||
|
||||
console.error("ERRO DETALHADO NO CATCH:", error);
|
||||
|
||||
toast({
|
||||
title: "Erro no Login",
|
||||
description: error instanceof Error ? error.message : "Ocorreu um erro inesperado.",
|
||||
description: error instanceof Error ? error.message : "Ocorreu um erro inesperado."
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ==================================================================
|
||||
// JSX VISUALMENTE RICO E UNIFICADO
|
||||
// ==================================================================
|
||||
return (
|
||||
// Usamos Card e CardContent para manter a consistência, mas o estilo principal
|
||||
// virá da página 'app/login/page.tsx' que envolve este componente.
|
||||
<Card className="w-full bg-transparent border-0 shadow-none">
|
||||
<CardContent className="p-0"> {/* Removemos o padding para dar controle à página pai */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu.email@exemplo.com"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="pl-10 h-11"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="username" // Boa prática de acessibilidade
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
{userRoles.length === 0 ? (
|
||||
// VISÃO 1: Formulário de Login (se nenhum perfil foi carregado ainda)
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" />
|
||||
<Input
|
||||
id="email" type="email" placeholder="seu.email@exemplo.com"
|
||||
value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="pl-10 h-11" required disabled={isLoading} autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" />
|
||||
<Input
|
||||
id="password" type={showPassword ? "text" : "password"} placeholder="Digite sua senha"
|
||||
value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="pl-10 pr-12 h-11" required disabled={isLoading} autoComplete="current-password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0 text-muted-foreground hover:text-foreground" disabled={isLoading}>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 text-base font-semibold" disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Entrar"}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
// VISÃO 2: Tela de Seleção de Perfil (se múltiplos perfis foram encontrados)
|
||||
<div className="space-y-4 animate-in fade-in-50">
|
||||
<h3 className="text-lg font-medium text-center text-foreground">Você tem múltiplos perfis</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">Selecione com qual perfil deseja entrar:</p>
|
||||
<div className="flex flex-col space-y-3 pt-2">
|
||||
{userRoles.map((role) => (
|
||||
<Button
|
||||
key={role}
|
||||
variant="outline"
|
||||
className="h-11 text-base"
|
||||
onClick={() => handleRoleSelection(role)}
|
||||
>
|
||||
Entrar como: {role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Digite sua senha"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="pl-10 pr-12 h-11"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password" // Boa prática de acessibilidade
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0 text-muted-foreground hover:text-foreground" disabled={isLoading}>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 text-base font-semibold" disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Entrar"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* O children permite que a página de login adicione links extras aqui */}
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,362 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie"; // Manteremos para o logout, se necessário
|
||||
import { api } from "@/services/api.mjs";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Search, Bell, Calendar, Clock, User, LogOut, Menu, X, Home, FileText, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface DoctorData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
cpf: string;
|
||||
crm: string;
|
||||
specialty: string;
|
||||
department: string;
|
||||
permissions: object;
|
||||
}
|
||||
|
||||
interface PatientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DoctorLayout({ children }: PatientLayoutProps) {
|
||||
const [doctorData, setDoctorData] = useState<DoctorData | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(0);
|
||||
const isMobile = windowWidth < 1024;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const userInfoString = localStorage.getItem("user_info");
|
||||
// --- ALTERAÇÃO PRINCIPAL AQUI ---
|
||||
// Procurando o token no localStorage, onde ele foi realmente salvo.
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (userInfoString && token) {
|
||||
const userInfo = JSON.parse(userInfoString);
|
||||
|
||||
setDoctorData({
|
||||
id: userInfo.id || "",
|
||||
name: userInfo.user_metadata?.full_name || "Doutor(a)",
|
||||
email: userInfo.email || "",
|
||||
specialty: userInfo.user_metadata?.specialty || "Especialidade",
|
||||
phone: userInfo.phone || "",
|
||||
cpf: "",
|
||||
crm: "",
|
||||
department: "",
|
||||
permissions: {},
|
||||
});
|
||||
} else {
|
||||
// Se não encontrar, aí sim redireciona.
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// O restante do seu código permanece exatamente o mesmo...
|
||||
useEffect(() => {
|
||||
const handleResize = () => setWindowWidth(window.innerWidth);
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
setShowLogoutDialog(true);
|
||||
};
|
||||
|
||||
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
// Chama a função centralizada para fazer o logout no servidor
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
|
||||
} finally {
|
||||
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
|
||||
localStorage.removeItem("user_info");
|
||||
localStorage.removeItem("token");
|
||||
Cookies.remove("access_token"); // Limpeza de segurança
|
||||
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a home
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => {
|
||||
setShowLogoutDialog(false);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
href: "/doctor/dashboard",
|
||||
icon: Home,
|
||||
label: "Dashboard",
|
||||
// Botão para o dashboard do médico
|
||||
},
|
||||
{
|
||||
href: "/doctor/medicos/consultas",
|
||||
icon: Calendar,
|
||||
label: "Consultas",
|
||||
// Botão para página de consultas marcadas do médico atual
|
||||
},
|
||||
{
|
||||
href: "#",
|
||||
icon: Clock,
|
||||
label: "Editor de Laudo",
|
||||
// Botão para página do editor de laudo
|
||||
},
|
||||
{
|
||||
href: "/doctor/medicos",
|
||||
icon: User,
|
||||
label: "Pacientes",
|
||||
// Botão para a página de visualização de todos os pacientes
|
||||
},
|
||||
{
|
||||
href: "/doctor/disponibilidade",
|
||||
icon: Calendar,
|
||||
label: "Disponibilidade",
|
||||
// Botão para o dashboard do médico
|
||||
},
|
||||
];
|
||||
|
||||
if (!doctorData) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
// O restante do seu código JSX permanece exatamente o mesmo
|
||||
<div className="min-h-screen bg-background flex">
|
||||
<div className={`bg-card border-r border transition-all duration-300 ${sidebarCollapsed ? "w-16" : "w-64"} fixed left-0 top-0 h-screen flex flex-col z-50`}>
|
||||
<div className="p-4 border-b border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-white rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1">
|
||||
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-blue-50 text-blue-600 border-r-2 border-blue-600" : "text-gray-600 hover:bg-gray-50"}`}>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
// ... (seu código anterior)
|
||||
{/* Sidebar para desktop */}
|
||||
<div className={`bg-white border-r border-gray-200 transition-all duration-300 ${sidebarCollapsed ? "w-16" : "w-64"} fixed left-0 top-0 h-screen flex flex-col z-50`}>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-white rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1">
|
||||
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-blue-50 text-blue-600 border-r-2 border-blue-600" : "text-gray-600 hover:bg-gray-50"}`}>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{doctorData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{doctorData.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{doctorData.specialty}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sidebarCollapsed && (
|
||||
<Avatar className="mx-auto">
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{doctorData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors text-muted-foreground hover:bg-accent cursor-pointer ${sidebarCollapsed ? "justify-center" : ""}`} onClick={handleLogout}>
|
||||
<LogOut className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">Sair</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobileMenuOpen && <div className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden" onClick={toggleMobileMenu}></div>}
|
||||
<div className={`bg-white border-r border-gray-200 fixed left-0 top-0 h-screen flex flex-col z-50 transition-transform duration-300 md:hidden ${isMobileMenuOpen ? "translate-x-0 w-64" : "-translate-x-full w-64"}`}>
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-white rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">Hospital System</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={toggleMobileMenu} className="p-1">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href} onClick={toggleMobileMenu}>
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-accent text-accent-foreground border-r-2 border-primary" : "text-muted-foreground hover:bg-accent"}`}>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{doctorData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{doctorData.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{doctorData.specialty}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-transparent"
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
toggleMobileMenu();
|
||||
}}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 flex flex-col transition-all duration-300 ${sidebarCollapsed ? "ml-16" : "ml-64"}`}>
|
||||
<header className="bg-card border-b border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input placeholder="Buscar paciente" className="pl-10 bg-background border" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-red-500 text-white text-xs">1</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,266 +0,0 @@
|
||||
// Caminho: [seu-caminho]/FinancierLayout.tsx
|
||||
"use client";
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api } from '@/services/api.mjs';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Search, Bell, Calendar, Clock, User, LogOut, Menu, X, Home, FileText, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface FinancierData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
cpf: string;
|
||||
department: string;
|
||||
permissions: object;
|
||||
}
|
||||
|
||||
interface PatientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FinancierLayout({ children }: PatientLayoutProps) {
|
||||
const [financierData, setFinancierData] = useState<FinancierData | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const userInfoString = localStorage.getItem("user_info");
|
||||
// --- ALTERAÇÃO 1: Buscando o token no localStorage ---
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (userInfoString && token) {
|
||||
const userInfo = JSON.parse(userInfoString);
|
||||
|
||||
setFinancierData({
|
||||
id: userInfo.id || "",
|
||||
name: userInfo.user_metadata?.full_name || "Financeiro",
|
||||
email: userInfo.email || "",
|
||||
department: userInfo.user_metadata?.department || "Departamento Financeiro",
|
||||
phone: userInfo.phone || "",
|
||||
cpf: "",
|
||||
permissions: {},
|
||||
});
|
||||
} else {
|
||||
// --- ALTERAÇÃO 2: Redirecionando para o login central ---
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
setShowLogoutDialog(true);
|
||||
};
|
||||
|
||||
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
// Chama a função centralizada para fazer o logout no servidor
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
|
||||
} finally {
|
||||
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
|
||||
localStorage.removeItem("user_info");
|
||||
localStorage.removeItem("token");
|
||||
Cookies.remove("access_token"); // Limpeza de segurança
|
||||
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a home
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => {
|
||||
setShowLogoutDialog(false);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ href: "#", icon: Home, label: "Dashboard" },
|
||||
{ href: "#", icon: Calendar, label: "Relatórios financeiros" },
|
||||
{ href: "#", icon: User, label: "Finanças Gerais" },
|
||||
{ href: "#", icon: Calendar, label: "Configurações" },
|
||||
];
|
||||
|
||||
if (!financierData) {
|
||||
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
// O restante do seu código JSX permanece inalterado
|
||||
<div className="min-h-screen bg-background flex">
|
||||
<div
|
||||
className={`bg-card border-r border-border transition-all duration-300 ${
|
||||
sidebarCollapsed ? "w-16" : "w-64"
|
||||
} fixed left-0 top-0 h-screen flex flex-col z-10`}
|
||||
>
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">
|
||||
MediConnect
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-medium">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{financierData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{financierData.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{financierData.department}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "w-full bg-transparent flex justify-center items-center p-2"
|
||||
: "w-full bg-transparent"
|
||||
}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut
|
||||
className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"}
|
||||
/>
|
||||
{!sidebarCollapsed && "Sair"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-1 flex flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? "ml-16" : "ml-64"
|
||||
}`}
|
||||
>
|
||||
<header className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Buscar paciente"
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente sair do sistema? Você precisará fazer login
|
||||
novamente para acessar sua conta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
Search,
|
||||
Bell,
|
||||
Settings,
|
||||
Users,
|
||||
UserCheck,
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
LogOut,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Home,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
interface PatientData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
cpf: string
|
||||
birthDate: string
|
||||
address: string
|
||||
}
|
||||
|
||||
interface HospitalLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function HospitalLayout({ children }: HospitalLayoutProps) {
|
||||
const [patientData, setPatientData] = useState<PatientData | null>(null)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const data = localStorage.getItem("patientData")
|
||||
if (data) {
|
||||
setPatientData(JSON.parse(data))
|
||||
} else {
|
||||
router.push("/patient/login")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const handleLogout = () => {
|
||||
setShowLogoutDialog(true)
|
||||
}
|
||||
|
||||
const confirmLogout = () => {
|
||||
localStorage.removeItem("patientData")
|
||||
setShowLogoutDialog(false)
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const cancelLogout = () => {
|
||||
setShowLogoutDialog(false)
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
href: "/patient/dashboard",
|
||||
icon: Home,
|
||||
label: "Dashboard",
|
||||
},
|
||||
{
|
||||
href: "/patient/appointments",
|
||||
icon: Calendar,
|
||||
label: "Minhas Consultas",
|
||||
},
|
||||
{
|
||||
href: "/patient/schedule",
|
||||
icon: Clock,
|
||||
label: "Agendar Consulta",
|
||||
},
|
||||
{
|
||||
href: "/patient/reports",
|
||||
icon: FileText,
|
||||
label: "Meus Laudos",
|
||||
},
|
||||
{
|
||||
href: "/patient/profile",
|
||||
icon: User,
|
||||
label: "Meus Dados",
|
||||
},
|
||||
]
|
||||
|
||||
if (!patientData) {
|
||||
return <div>Carregando...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`bg-card border-r border-border transition-all duration-300 ${sidebarCollapsed ? "w-16" : "w-64"} h-screen flex flex-col`}
|
||||
>
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1">
|
||||
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
|
||||
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{patientData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{patientData.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{patientData.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full bg-transparent" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input placeholder="Buscar paciente" className="pl-10 bg-background border-border" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Logout confirmation dialog */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
components/layout/DashboardLayout.tsx
Normal file
166
components/layout/DashboardLayout.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
// Caminho: components/layout/DashboardLayout.tsx
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { autenticacaoApi } from '@/services/autenticacaoApi'; // Importamos o serviço correto
|
||||
|
||||
// Importe todos os componentes UI e ícones que você usa
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Search, Bell, LogOut, ChevronLeft, ChevronRight, LucideIcon } from "lucide-react";
|
||||
|
||||
// Tipos para as props do nosso layout genérico
|
||||
export interface MenuItem {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
name: string;
|
||||
secondaryText: string; // Pode ser o email, departamento, especialidade, etc.
|
||||
avatarFallback: string;
|
||||
}
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
menuItems: MenuItem[];
|
||||
userProfile: UserProfile | null;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children, menuItems, userProfile }: DashboardLayoutProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Lógica de responsividade (idêntica para todos)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Funções de logout (idênticas para todos)
|
||||
const handleLogout = () => setShowLogoutDialog(true);
|
||||
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
await autenticacaoApi.logout();
|
||||
} catch (error) {
|
||||
console.error("Falha ao fazer logout no servidor:", error);
|
||||
} finally {
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a home page
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => setShowLogoutDialog(false);
|
||||
|
||||
if (!userProfile) {
|
||||
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Sidebar (agora genérica) */}
|
||||
<div className={`bg-card border-r border-border transition-all duration-300 ${sidebarCollapsed ? "w-16" : "w-64"} fixed left-0 top-0 h-screen flex flex-col z-10`}>
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1">
|
||||
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"}`}>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>{userProfile.avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{userProfile.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{userProfile.secondaryText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className={sidebarCollapsed ? "w-full bg-transparent flex justify-center items-center p-2" : "w-full bg-transparent"} onClick={handleLogout}>
|
||||
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
|
||||
{!sidebarCollapsed && "Sair"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content (idêntico) */}
|
||||
<div className={`flex-1 flex flex-col transition-all duration-300 ${sidebarCollapsed ? "ml-16" : "ml-64"}`}>
|
||||
<header className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input placeholder="Buscar..." className="pl-10 bg-background border-border" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">1</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Dialog de Logout (idêntico) */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>Cancelar</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}><LogOut className="mr-2 h-4 w-4" />Sair</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
// Caminho: [seu-caminho]/ManagerLayout.tsx
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie"; // Mantido apenas para a limpeza de segurança no logout
|
||||
import { api } from '@/services/api.mjs';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Search, Bell, Calendar, User, LogOut, ChevronLeft, ChevronRight, Home } from "lucide-react";
|
||||
|
||||
interface ManagerData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
cpf: string;
|
||||
department: string;
|
||||
permissions: object;
|
||||
}
|
||||
|
||||
interface ManagerLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ManagerLayout({ children }: ManagerLayoutProps) {
|
||||
const [managerData, setManagerData] = useState<ManagerData | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const userInfoString = localStorage.getItem("user_info");
|
||||
// --- ALTERAÇÃO 1: Buscando o token no localStorage ---
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (userInfoString && token) {
|
||||
const userInfo = JSON.parse(userInfoString);
|
||||
|
||||
setManagerData({
|
||||
id: userInfo.id || "",
|
||||
name: userInfo.user_metadata?.full_name || "Gestor(a)",
|
||||
email: userInfo.email || "",
|
||||
department: userInfo.user_metadata?.role || "Gestão",
|
||||
phone: userInfo.phone || "",
|
||||
cpf: "",
|
||||
permissions: {},
|
||||
});
|
||||
} else {
|
||||
// O redirecionamento para /login já estava correto. Ótimo!
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => setShowLogoutDialog(true);
|
||||
|
||||
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
// Chama a função centralizada para fazer o logout no servidor
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
|
||||
} finally {
|
||||
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
|
||||
localStorage.removeItem("user_info");
|
||||
localStorage.removeItem("token");
|
||||
Cookies.remove("access_token"); // Limpeza de segurança
|
||||
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a home
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => setShowLogoutDialog(false);
|
||||
|
||||
const menuItems = [
|
||||
{ href: "#dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "#reports", icon: Calendar, label: "Relatórios gerenciais" },
|
||||
{ href: "#users", icon: User, label: "Gestão de Usuários" },
|
||||
{ href: "#doctors", icon: User, label: "Gestão de Médicos" },
|
||||
{ href: "#settings", icon: Calendar, label: "Configurações" },
|
||||
];
|
||||
|
||||
if (!managerData) {
|
||||
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<div
|
||||
className={`bg-white border-r border-gray-200 transition-all duration-300 fixed top-0 h-screen flex flex-col z-30 ${sidebarCollapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-white rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">
|
||||
MediConnect
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1"
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link key={item.label} href={item.href}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-blue-50 text-blue-600 border-r-2 border-blue-600" : "text-gray-600 hover:bg-gray-50"}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>{managerData.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{managerData.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{managerData.department}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={sidebarCollapsed ? "w-full bg-transparent flex justify-center items-center p-2" : "w-full bg-transparent"}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
|
||||
{!sidebarCollapsed && "Sair"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 flex flex-col transition-all duration-300 w-full ${sidebarCollapsed ? "ml-16" : "ml-64"}`}>
|
||||
<header className="bg-white border-b border-gray-200 px-4 md:px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input placeholder="Buscar paciente" className="pl-10 bg-gray-50 border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-red-500 text-white text-xs">1</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>Cancelar</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}><LogOut className="mr-2 h-4 w-4" />Sair</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Cookies from "js-cookie";
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { api } from "@/services/api.mjs"; // Importando nosso cliente de API
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Search, Bell, User, LogOut, FileText, Clock, Calendar, Home, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
interface PatientData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
cpf: string
|
||||
birthDate: string
|
||||
address: string
|
||||
}
|
||||
|
||||
interface PatientLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// --- ALTERAÇÃO 1: Renomeando o componente para maior clareza ---
|
||||
export default function PatientLayout({ children }: PatientLayoutProps) {
|
||||
const [patientData, setPatientData] = useState<PatientData | null>(null)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setSidebarCollapsed(true)
|
||||
} else {
|
||||
setSidebarCollapsed(false)
|
||||
}
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const userInfoString = localStorage.getItem("user_info");
|
||||
// --- ALTERAÇÃO 2: Buscando o token no localStorage ---
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (userInfoString && token) {
|
||||
const userInfo = JSON.parse(userInfoString);
|
||||
|
||||
setPatientData({
|
||||
name: userInfo.user_metadata?.full_name || "Paciente",
|
||||
email: userInfo.email || "",
|
||||
phone: userInfo.phone || "",
|
||||
cpf: "",
|
||||
birthDate: "",
|
||||
address: "",
|
||||
});
|
||||
} else {
|
||||
// --- ALTERAÇÃO 3: Redirecionando para o login central ---
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => setShowLogoutDialog(true)
|
||||
|
||||
// --- ALTERAÇÃO 4: Função de logout completa e padronizada ---
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
// Chama a função centralizada para fazer o logout no servidor
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
console.error("Erro ao tentar fazer logout no servidor:", error);
|
||||
} finally {
|
||||
// Limpeza completa e consistente do estado local
|
||||
localStorage.removeItem("user_info");
|
||||
localStorage.removeItem("token");
|
||||
Cookies.remove("access_token"); // Limpeza de segurança
|
||||
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a página inicial
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => setShowLogoutDialog(false)
|
||||
|
||||
const menuItems = [
|
||||
{ href: "/patient/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "/patient/appointments", icon: Calendar, label: "Minhas Consultas" },
|
||||
{ href: "/patient/schedule", icon: Clock, label: "Agendar Consulta" },
|
||||
{ href: "/patient/reports", icon: FileText, label: "Meus Laudos" },
|
||||
{ href: "/patient/profile", icon: User, label: "Meus Dados" },
|
||||
]
|
||||
|
||||
if (!patientData) {
|
||||
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`bg-card border-r border-border transition-all duration-300 ${
|
||||
sidebarCollapsed ? "w-16" : "w-64"
|
||||
} fixed left-0 top-0 h-screen flex flex-col z-10`}
|
||||
>
|
||||
{/* Header da Sidebar */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/" && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-medium">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Rodapé com Avatar e Logout */}
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{patientData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{patientData.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{patientData.email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Botão Sair - ajustado para responsividade */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "w-full bg-transparent flex justify-center items-center p-2" // Centraliza o ícone quando colapsado
|
||||
: "w-full bg-transparent"
|
||||
}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut
|
||||
className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"}
|
||||
/>{" "}
|
||||
{/* Remove margem quando colapsado */}
|
||||
{!sidebarCollapsed && "Sair"}{" "}
|
||||
{/* Mostra o texto apenas quando não está colapsado */}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? "ml-16" : "ml-64"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Buscar paciente"
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Logout confirmation dialog */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente sair do sistema? Você precisará fazer login
|
||||
novamente para acessar sua conta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,257 +0,0 @@
|
||||
// Caminho: app/(secretary)/layout.tsx (ou o caminho do seu arquivo)
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import Cookies from "js-cookie";
|
||||
import { api } from '@/services/api.mjs'; // Importando nosso cliente de API central
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Search, Bell, Calendar, Clock, User, LogOut, Home, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface SecretaryData {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
cpf: string
|
||||
employeeId: string
|
||||
department: string
|
||||
permissions: object
|
||||
}
|
||||
|
||||
interface SecretaryLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function SecretaryLayout({ children }: SecretaryLayoutProps) {
|
||||
const [secretaryData, setSecretaryData] = useState<SecretaryData | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const userInfoString = localStorage.getItem("user_info");
|
||||
// --- ALTERAÇÃO 1: Buscando o token no localStorage ---
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (userInfoString && token) {
|
||||
const userInfo = JSON.parse(userInfoString);
|
||||
|
||||
setSecretaryData({
|
||||
id: userInfo.id || "",
|
||||
name: userInfo.user_metadata?.full_name || "Secretária",
|
||||
email: userInfo.email || "",
|
||||
department: userInfo.user_metadata?.department || "Atendimento",
|
||||
phone: userInfo.phone || "",
|
||||
cpf: "",
|
||||
employeeId: "",
|
||||
permissions: {},
|
||||
});
|
||||
} else {
|
||||
// --- ALTERAÇÃO 2: Redirecionando para o login central ---
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setSidebarCollapsed(true)
|
||||
} else {
|
||||
setSidebarCollapsed(false)
|
||||
}
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => setShowLogoutDialog(true)
|
||||
|
||||
// --- ALTERAÇÃO 3: Função de logout completa e padronizada ---
|
||||
const confirmLogout = async () => {
|
||||
try {
|
||||
// Chama a função centralizada para fazer o logout no servidor
|
||||
await api.logout();
|
||||
} catch (error) {
|
||||
console.error("Erro ao tentar fazer logout no servidor:", error);
|
||||
} finally {
|
||||
// Limpeza completa e consistente do estado local
|
||||
localStorage.removeItem("user_info");
|
||||
localStorage.removeItem("token");
|
||||
Cookies.remove("access_token"); // Limpeza de segurança
|
||||
|
||||
setShowLogoutDialog(false);
|
||||
router.push("/"); // Redireciona para a página inicial
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogout = () => setShowLogoutDialog(false)
|
||||
|
||||
const menuItems = [
|
||||
{ href: "/secretary/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "/secretary/appointments", icon: Calendar, label: "Consultas" },
|
||||
{ href: "/secretary/schedule", icon: Clock, label: "Agendar Consulta" },
|
||||
{ href: "/secretary/pacientes", icon: User, label: "Pacientes" },
|
||||
]
|
||||
|
||||
if (!secretaryData) {
|
||||
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`bg-card border-r border-border transition-all duration-300
|
||||
${sidebarCollapsed ? "w-16" : "w-64"}
|
||||
fixed left-0 top-0 h-screen flex flex-col z-10`}
|
||||
>
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">MediConnect</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4 mt-auto">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" />
|
||||
<AvatarFallback>
|
||||
{secretaryData.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{secretaryData.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{secretaryData.email}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "w-full bg-transparent flex justify-center items-center p-2"
|
||||
: "w-full bg-transparent"
|
||||
}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut
|
||||
className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"}
|
||||
/>
|
||||
{!sidebarCollapsed && "Sair"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col transition-all duration-300 ${sidebarCollapsed ? "ml-16" : "ml-64"
|
||||
}`}
|
||||
>
|
||||
<header className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Buscar paciente"
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Logout confirmation dialog */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar Saída</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={cancelLogout}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// Caminho: components/ui/button.tsx (Completo e Corrigido)
|
||||
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
@ -35,25 +37,25 @@ const buttonVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@ -31,7 +31,7 @@ const toastVariants = cva(
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
'destructive group border-destructive bg-destructive text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
75
config/dashboard.config.ts
Normal file
75
config/dashboard.config.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// Caminho: config/dashboard.config.ts
|
||||
import { Home, Calendar, Clock, User, FileText } from "lucide-react";
|
||||
import type { MenuItem, UserProfile } from "@/components/layout/DashboardLayout";
|
||||
|
||||
// Função auxiliar para criar o fallback do avatar
|
||||
const createAvatarFallback = (name: string = ""): string => {
|
||||
return name.split(" ").map((n) => n[0]).join("").toUpperCase();
|
||||
};
|
||||
|
||||
// Função para extrair o perfil do usuário de forma padronizada
|
||||
const getProfile = (userInfo: any, roleDefaults: { name: string, secondaryText: string }): UserProfile => {
|
||||
const name = userInfo.user_metadata?.full_name || roleDefaults.name;
|
||||
return {
|
||||
name: name,
|
||||
secondaryText: userInfo.user_metadata?.specialty || userInfo.user_metadata?.department || userInfo.email || roleDefaults.secondaryText,
|
||||
avatarFallback: createAvatarFallback(name),
|
||||
};
|
||||
};
|
||||
|
||||
interface RoleConfig {
|
||||
menuItems: MenuItem[];
|
||||
getUserProfile: (userInfo: any) => UserProfile;
|
||||
}
|
||||
|
||||
export const dashboardConfig: Record<string, RoleConfig> = {
|
||||
doctor: {
|
||||
menuItems: [
|
||||
{ href: "/medicos/doctor/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "/medicos/doctor/medicos/consultas", icon: Calendar, label: "Consultas" },
|
||||
{ href: "/medicos/doctor/[id]/laudos", icon: Clock, label: "Editor de Laudo" },
|
||||
{ href: "/medicos/doctor/medicos", icon: User, label: "Pacientes" },
|
||||
{ href: "/medicos/doctor/disponibilidade", icon: Calendar, label: "Disponibilidade" },
|
||||
],
|
||||
getUserProfile: (userInfo) => getProfile(userInfo, { name: "Doutor(a)", secondaryText: "Especialidade" }),
|
||||
},
|
||||
patient: {
|
||||
menuItems: [
|
||||
{ href: "/patient/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "/patient/appointments", icon: Calendar, label: "Minhas Consultas" },
|
||||
{ href: "/patient/schedule", icon: Clock, label: "Agendar Consulta" },
|
||||
{ href: "/patient/reports", icon: FileText, label: "Meus Laudos" },
|
||||
{ href: "/patient/profile", icon: User, label: "Meus Dados" },
|
||||
],
|
||||
getUserProfile: (userInfo) => getProfile(userInfo, { name: "Paciente", secondaryText: "Área do Paciente" }),
|
||||
},
|
||||
secretary: {
|
||||
menuItems: [
|
||||
{ href: "/manager/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "/manager/relatorios", icon: Calendar, label: "Relatórios gerenciais" },
|
||||
{ href: "/manager/usuario", icon: User, label: "Gestão de Usuários" },
|
||||
{ href: "/manager/home", icon: User, label: "Gestão de Médicos" },
|
||||
{ href: "/manager/configuracoes", icon: Calendar, label: "Configurações" },
|
||||
],
|
||||
getUserProfile: (userInfo) => getProfile(userInfo, { name: "Secretária", secondaryText: "Atendimento" }),
|
||||
},
|
||||
manager: {
|
||||
menuItems: [
|
||||
{ href: "/manager/dashboard", icon: Home, label: "Dashboard" },
|
||||
{ href: "#", icon: Calendar, label: "Relatórios gerenciais" },
|
||||
{ href: "/manager/usuario", icon: User, label: "Gestão de Usuários" },
|
||||
{ href: "/manager/home", icon: User, label: "Gestão de Médicos" },
|
||||
{ href: "#", icon: Calendar, label: "Configurações" },
|
||||
],
|
||||
getUserProfile: (userInfo) => getProfile(userInfo, { name: "Gestor(a)", secondaryText: "Gestão" }),
|
||||
},
|
||||
financier: {
|
||||
menuItems: [
|
||||
{ href: "#", icon: Home, label: "Dashboard" },
|
||||
{ href: "#", icon: Calendar, label: "Relatórios financeiros" },
|
||||
{ href: "#", icon: User, label: "Finanças Gerais" },
|
||||
{ href: "#", icon: Calendar, label: "Configurações" },
|
||||
],
|
||||
getUserProfile: (userInfo) => getProfile(userInfo, { name: "Financeiro", secondaryText: "Departamento Financeiro" }),
|
||||
}
|
||||
};
|
||||
302
package-lock.json
generated
302
package-lock.json
generated
@ -43,6 +43,7 @@
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
@ -70,13 +71,13 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@ -2496,9 +2497,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.18.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.10.tgz",
|
||||
"integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==",
|
||||
"version": "22.18.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz",
|
||||
"integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2578,6 +2579,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@ -2615,6 +2622,17 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz",
|
||||
@ -2668,6 +2686,19 @@
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001743",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
|
||||
@ -2741,6 +2772,18 @@
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@ -2896,6 +2939,15 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz",
|
||||
@ -2922,6 +2974,20 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.219",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.219.tgz",
|
||||
@ -2982,6 +3048,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@ -3024,6 +3135,42 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@ -3037,6 +3184,15 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/geist": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geist/-/geist-1.5.1.tgz",
|
||||
@ -3046,6 +3202,30 @@
|
||||
"next": ">=13.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
@ -3055,12 +3235,76 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz",
|
||||
@ -3087,7 +3331,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
"jiti": "lib/jiti-cli"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
@ -3413,7 +3657,16 @@
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
"markdown-it": "bin/markdown-it"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
@ -3422,6 +3675,27 @@
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@ -3849,6 +4123,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
@ -4228,9 +4508,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@ -44,6 +44,7 @@
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
@ -71,12 +72,12 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
184
services/0detalhes.txt
Normal file
184
services/0detalhes.txt
Normal file
@ -0,0 +1,184 @@
|
||||
======================================================================
|
||||
DOCUMENTAÇÃO DA CAMADA DE SERVIÇO (SERVICES)
|
||||
======================================================================
|
||||
|
||||
Este documento descreve a arquitetura e o funcionamento da camada de serviço,
|
||||
responsável por toda a comunicação com o backend (Supabase API).
|
||||
|
||||
----------------------------------------------------------------------
|
||||
ARQUITETURA GERAL
|
||||
----------------------------------------------------------------------
|
||||
|
||||
A camada de serviço é composta por 12 arquivos, organizados por módulos
|
||||
de funcionalidade da API. A arquitetura é centralizada em um arquivo
|
||||
principal `api.ts` que configura o Axios, enquanto os outros arquivos
|
||||
consomem essa configuração para realizar as chamadas específicas.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
1. ARQUIVO PRINCIPAL: api.ts
|
||||
----------------------------------------------------------------------
|
||||
|
||||
- **Propósito:** Este é o coração da camada de serviço. Ele cria e exporta
|
||||
uma instância centralizada do Axios pré-configurada para interagir com
|
||||
a API do Supabase.
|
||||
|
||||
- **Configurações Principais:**
|
||||
- **baseURL:** Aponta para `https://yuanqfswhberkoevtmfr.supabase.co`.
|
||||
- **apikey:** A chave pública (anon key) do Supabase é adicionada como
|
||||
um cabeçalho padrão em TODAS as requisições.
|
||||
|
||||
- **Interceptor de Requisição (Request Interceptor):**
|
||||
- Antes de qualquer requisição ser enviada, o interceptor busca por um
|
||||
cookie chamado `supabase-token`.
|
||||
- Se o token for encontrado, ele é adicionado ao cabeçalho `Authorization`
|
||||
como um `Bearer Token`.
|
||||
- Isso automatiza o processo de autenticação para todas as rotas protegidas,
|
||||
evitando a necessidade de adicionar o token manualmente em cada chamada.
|
||||
|
||||
- **Importante:** Este arquivo NÃO contém nenhuma função de endpoint (como
|
||||
login ou listagem de médicos). Sua única responsabilidade é a configuração
|
||||
do cliente HTTP.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
2. MÓDULOS DE SERVIÇO
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Cada arquivo a seguir representa um módulo da API e exporta um objeto
|
||||
com funções assíncronas para interagir com os endpoints.
|
||||
|
||||
---
|
||||
### 2.1. autenticacaoApi.ts
|
||||
|
||||
- **Propósito:** Gerencia todas as operações de autenticação.
|
||||
- **Observação:** Este módulo utiliza `fetch` diretamente em vez da instância
|
||||
`api` do Axios. Isso é necessário porque as funções de login são as que
|
||||
OBTÊM o token, que o interceptor do Axios precisa para funcionar. Ele também
|
||||
gerencia a gravação e remoção do `supabase-token` nos cookies do navegador.
|
||||
|
||||
- **Funções Exportadas:**
|
||||
- `loginWithEmailAndPassword(email, password)`: Envia credenciais para `POST /auth/v1/token?grant_type=password`, recebe o token de acesso e o armazena nos cookies.
|
||||
- `logout()`: Envia uma requisição para `POST /auth/v1/logout` para invalidar a sessão no Supabase e remove o token dos cookies.
|
||||
- `sendMagicLink(email, redirectTo)`: Envia um email para `POST /auth/v1/otp` para login sem senha.
|
||||
- `renewToken(refreshToken)`: Usa um refresh token para obter um novo token de acesso via `POST /auth/v1/token?grant_type=refresh_token`.
|
||||
|
||||
---
|
||||
### 2.2. atribuicoesApi.ts
|
||||
|
||||
- **Propósito:** Gerencia as atribuições de pacientes a profissionais.
|
||||
- **Tabela Alvo:** `patient_assignments`
|
||||
- **Funções Exportadas:**
|
||||
- `list()`: Busca a lista de todas as atribuições (`GET /rest/v1/patient_assignments`).
|
||||
- `create(data)`: Cria uma nova atribuição (`POST /rest/v1/patient_assignments`).
|
||||
|
||||
---
|
||||
### 2.3. avatarsApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o upload e a remoção de avatares no Supabase Storage.
|
||||
- **Observação:** As URLs e o método de envio (multipart/form-data) são
|
||||
específicos para o serviço de Storage do Supabase.
|
||||
|
||||
- **Funções Exportadas:**
|
||||
- `upload(userId, file)`: Envia um arquivo de imagem para `POST /storage/v1/object/avatars/{userId}/avatar`.
|
||||
- `remove(userId)`: Deleta o avatar de um usuário (`DELETE /storage/v1/object/avatars/{userId}/avatar`).
|
||||
- `getPublicUrl(userId, ext)`: Monta e retorna a URL pública para acessar a imagem do avatar, não faz uma chamada de API.
|
||||
|
||||
---
|
||||
### 2.4. medicosApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o CRUD (Create, Read, Update, Delete) completo para o recurso de médicos.
|
||||
- **Tabela Alvo:** `doctors`
|
||||
- **Funções Exportadas:**
|
||||
- `list()`: `GET /rest/v1/doctors`
|
||||
- `getById(id)`: `GET /rest/v1/doctors?id=eq.{id}`
|
||||
- `create(data)`: `POST /rest/v1/doctors`
|
||||
- `update(id, data)`: `PATCH /rest/v1/doctors?id=eq.{id}`
|
||||
- `delete(id)`: `DELETE /rest/v1/doctors?id=eq.{id}`
|
||||
|
||||
---
|
||||
### 2.5. pacientesApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o CRUD completo para o recurso de pacientes.
|
||||
- **Tabela Alvo:** `patients`
|
||||
- **Funções Exportadas:** CRUD padrão (`list`, `getById`, `create`, `update`, `delete`).
|
||||
|
||||
---
|
||||
### 2.6. perfisApi.ts
|
||||
|
||||
- **Propósito:** Gerencia a listagem e atualização de perfis de usuários.
|
||||
- **Tabela Alvo:** `profiles`
|
||||
- **Funções Exportadas:**
|
||||
- `list()`: `GET /rest/v1/profiles`
|
||||
- `update(userId, data)`: `PATCH /rest/v1/profiles?id=eq.{userId}`
|
||||
|
||||
---
|
||||
### 2.7. relatoriosApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o CRUD completo para o recurso de relatórios.
|
||||
- **Tabela Alvo:** `reports`
|
||||
- **Funções Exportadas:** CRUD padrão (`list`, `getById`, `create`, `update`, `delete`).
|
||||
|
||||
---
|
||||
### 2.8. usuariosApi.ts
|
||||
|
||||
- **Propósito:** Agrupa endpoints relacionados a usuários que não são CRUD direto da tabela `profiles`.
|
||||
- **Funções Exportadas:**
|
||||
- `listRoles()`: Busca as funções (roles) dos usuários (`GET /rest/v1/user_roles`).
|
||||
- `createUser(data)`: Chama uma Supabase Function para criar um novo usuário (`POST /functions/v1/create-user`).
|
||||
- `getCurrentUser()`: Obtém os dados do usuário atualmente autenticado (`GET /auth/v1/user`).
|
||||
- `getFullData(userId)`: Chama uma Supabase Function para obter dados consolidados de um usuário (`GET /functions/v1/user-info`).
|
||||
|
||||
---
|
||||
### 2.9. smsApi.ts
|
||||
|
||||
- **Propósito:** Responsável pelo envio de mensagens SMS.
|
||||
- **Funções Exportadas:**
|
||||
- `send(data)`: Chama a Supabase Function para enviar um SMS (`POST /functions/v1/send-sms`).
|
||||
|
||||
---
|
||||
### 2.10. agendamentosApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o CRUD de agendamentos e a busca por horários.
|
||||
- **Tabela Alvo:** `appointments`
|
||||
- **Funções Exportadas:**
|
||||
- CRUD padrão (`list`, `getById`, `create`, `update`, `delete`).
|
||||
- `searchAvailableSlots(data)`: Chama a Supabase Function para buscar horários disponíveis (`POST /functions/v1/get-available-slots`).
|
||||
|
||||
---
|
||||
### 2.11. disponibilidadeApi.ts
|
||||
|
||||
- **Propósito:** Gerencia o CRUD completo para a disponibilidade dos médicos.
|
||||
- **Tabela Alvo:** `doctor_availability`
|
||||
- **Funções Exportadas:** CRUD padrão (`list`, `getById`, `create`, `update`, `delete`).
|
||||
|
||||
---
|
||||
### 2.12. excecoesApi.ts
|
||||
|
||||
- **Propósito:** Gerencia as exceções (bloqueios/liberações) na agenda dos médicos.
|
||||
- **Tabela Alvo:** `doctor_exceptions`
|
||||
- **Funções Exportadas:**
|
||||
- `list()`: `GET /rest/v1/doctor_exceptions`
|
||||
- `create(data)`: `POST /rest/v1/doctor_exceptions`
|
||||
- `delete(id)`: `DELETE /rest/v1/doctor_exceptions?id=eq.{id}`
|
||||
|
||||
----------------------------------------------------------------------
|
||||
COMO UTILIZAR
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Para usar qualquer uma dessas funções em um componente ou página do Next.js,
|
||||
basta importar o módulo desejado e chamar a função. O tratamento de erros
|
||||
(com `try/catch`) e o gerenciamento de estado (loading, data, error) devem
|
||||
ser feitos no local onde a função é chamada.
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```typescript
|
||||
import { medicosApi } from './services/medicosApi';
|
||||
|
||||
async function fetchDoctors() {
|
||||
try {
|
||||
const doctors = await medicosApi.list();
|
||||
console.log(doctors);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar médicos:", error);
|
||||
}
|
||||
}
|
||||
95
services/agendamentosApi.ts
Normal file
95
services/agendamentosApi.ts
Normal file
@ -0,0 +1,95 @@
|
||||
// Caminho: services/agendamentosApi.ts (Completo, com adição de update e delete)
|
||||
|
||||
import api from './api';
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
patient_id: string;
|
||||
scheduled_at: string;
|
||||
duration_minutes?: number;
|
||||
status: 'requested' | 'confirmed' | 'completed' | 'cancelled';
|
||||
created_by?: string;
|
||||
cancel_reason?: string;
|
||||
reschedule_reason?: string;
|
||||
appointment_type?: 'presencial' | 'telemedicina';
|
||||
notes?: string;
|
||||
doctors?: {
|
||||
full_name: string;
|
||||
specialty: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateAppointmentData = Omit<Appointment, 'id' | 'doctors' | 'cancel_reason' | 'reschedule_reason'>;
|
||||
export type UpdateAppointmentData = Partial<Omit<Appointment, 'id' | 'doctors' | 'created_by' | 'patient_id' | 'doctor_id'>>;
|
||||
|
||||
export interface AvailableSlot {
|
||||
time: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export const agendamentosApi = {
|
||||
listByPatient: async (patientId: string): Promise<Appointment[]> => {
|
||||
const response = await api.get<Appointment[]>('/rest/v1/appointments', {
|
||||
params: {
|
||||
patient_id: `eq.${patientId}`,
|
||||
select: '*,doctors(full_name,specialty)',
|
||||
order: 'scheduled_at.asc',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateAppointmentData): Promise<Appointment> => {
|
||||
const response = await api.post<Appointment[]>('/rest/v1/appointments', data, {
|
||||
headers: { 'Prefer': 'return=representation' },
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Atualiza um agendamento existente (PATCH).
|
||||
* @param id - O UUID do agendamento.
|
||||
* @param data - Os campos a serem atualizados.
|
||||
*/
|
||||
update: async (id: string, data: UpdateAppointmentData): Promise<Appointment> => {
|
||||
const response = await api.patch<Appointment[]>(`/rest/v1/appointments?id=eq.${id}`, data, {
|
||||
headers: {
|
||||
'Prefer': 'return=representation',
|
||||
},
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Exclui um agendamento.
|
||||
* @param id - O UUID do agendamento a ser excluído.
|
||||
*/
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/rest/v1/appointments?id=eq.${id}`);
|
||||
},
|
||||
|
||||
getAvailableSlots: async (doctorId: string, date: string): Promise<{ slots: AvailableSlot[] }> => {
|
||||
const response = await api.post<{ slots: AvailableSlot[] }>('/functions/v1/get-available-slots', {
|
||||
doctor_id: doctorId,
|
||||
date: date,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMockAppointments: async (): Promise<Appointment[]> => {
|
||||
const response = await api.get<any[]>('https://mock.apidog.com/m1/1053378-0-default/rest/v1/doctors');
|
||||
return response.data.map((doctor: any, index: number) => ({
|
||||
id: `mock-${index + 1}`,
|
||||
doctor_id: doctor.id || `doc-mock-${index + 1}`,
|
||||
patient_id: 'patient-mock-1',
|
||||
scheduled_at: new Date(Date.now() + 86400000 * (index + 2)).toISOString(),
|
||||
status: index % 2 === 0 ? 'confirmed' : 'requested',
|
||||
appointment_type: index % 2 === 0 ? 'presencial' : 'telemedicina',
|
||||
doctors: {
|
||||
full_name: doctor.full_name || `Dr. Exemplo ${index + 1}`,
|
||||
specialty: doctor.specialty || 'Especialidade',
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
181
services/api.mjs
181
services/api.mjs
@ -1,181 +0,0 @@
|
||||
// Caminho: [seu-caminho]/services/api.mjs
|
||||
|
||||
const BASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
export async function loginWithEmailAndPassword(email, password) {
|
||||
const response = await fetch(`${BASE_URL}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error_description || "Credenciais inválidas.");
|
||||
}
|
||||
|
||||
if (data.access_token && typeof window !== 'undefined') {
|
||||
// Padronizando para salvar o token no localStorage
|
||||
localStorage.setItem("token", data.access_token);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- NOVA FUNÇÃO DE LOGOUT CENTRALIZADA ---
|
||||
async function logout() {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return; // Se não há token, não há o que fazer
|
||||
|
||||
try {
|
||||
await fetch(`${BASE_URL}/auth/v1/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"apikey": API_KEY,
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Mesmo que a chamada falhe, o logout no cliente deve continuar.
|
||||
// O token pode já ter expirado no servidor, por exemplo.
|
||||
console.error("Falha ao invalidar token no servidor (isso pode ser normal se o token já expirou):", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem("token") : null;
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": API_KEY,
|
||||
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
const API_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
export const apikey = API_KEY;
|
||||
let loginPromise = null;
|
||||
|
||||
export async function login() {
|
||||
console.log("🔐 Iniciando login...");
|
||||
const res = await fetch(`${BASE_URL}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: API_KEY,
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
console.error("❌ Erro no login:", res.status, msg);
|
||||
throw new Error(`Erro ao autenticar: ${res.status} - ${msg}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log("✅ Login bem-sucedido:", data);
|
||||
|
||||
if (typeof window !== "undefined" && data.access_token) {
|
||||
localStorage.setItem("token", data.access_token);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
if (!loginPromise) loginPromise = login();
|
||||
|
||||
try {
|
||||
await loginPromise;
|
||||
} catch (error) {
|
||||
console.error("⚠️ Falha ao autenticar:", error);
|
||||
} finally {
|
||||
loginPromise = null;
|
||||
}
|
||||
|
||||
let token =
|
||||
typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
|
||||
if (!token) {
|
||||
console.warn("⚠️ Token não encontrado, refazendo login...");
|
||||
const data = await login();
|
||||
token = data.access_token;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
apikey: API_KEY,
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const fullUrl =
|
||||
endpoint.startsWith("/rest/v1") || endpoint.startsWith("/functions/")
|
||||
? `${BASE_URL}${endpoint}`
|
||||
: `${BASE_URL}/rest/v1${endpoint}`;
|
||||
|
||||
console.log("🌐 Requisição para:", fullUrl, "com headers:", headers);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = await response.json();
|
||||
} catch (e) {
|
||||
errorBody = await response.text();
|
||||
}
|
||||
throw new Error(`Erro HTTP: ${response.status} - ${JSON.stringify(errorBody)}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return {};
|
||||
return await response.json();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro na requisição:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Adicionamos a função de logout ao nosso objeto de API exportado
|
||||
export const api = {
|
||||
get: (endpoint, options) => request(endpoint, { method: "GET", ...options }),
|
||||
post: (endpoint, data, options) => request(endpoint, { method: "POST", body: JSON.stringify(data), ...options }),
|
||||
patch: (endpoint, data, options) => request(endpoint, { method: "PATCH", body: JSON.stringify(data), ...options }),
|
||||
delete: (endpoint, options) => request(endpoint, { method: "DELETE", ...options }),
|
||||
logout: logout, // <-- EXPORTANDO A NOVA FUNÇÃO
|
||||
};
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
console.error("❌ Erro HTTP:", response.status, msg);
|
||||
throw new Error(`Erro HTTP: ${response.status} - Detalhes: ${msg}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) return {};
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (endpoint, options) => request(endpoint, { method: "GET", ...options }),
|
||||
post: (endpoint, data) =>
|
||||
request(endpoint, { method: "POST", body: JSON.stringify(data) }),
|
||||
patch: (endpoint, data) =>
|
||||
request(endpoint, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
delete: (endpoint) => request(endpoint, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
31
services/api.ts
Normal file
31
services/api.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Caminho: services/api.ts
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const baseURL = 'https://yuanqfswhberkoevtmfr.supabase.co';
|
||||
const apiKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
'apikey': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor para adicionar o token de autenticação em cada requisição
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = Cookies.get('supabase-token'); // Nome do cookie onde o token é armazenado
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
221
services/api/apiService.ts
Normal file
221
services/api/apiService.ts
Normal file
@ -0,0 +1,221 @@
|
||||
// apiService.ts (V4)
|
||||
import {
|
||||
AuthResponse, UserProfile, Patient, Doctor, Appointment, NewAppointmentPayload,
|
||||
AvailableSlot, Report, ReportInput, DoctorAvailability, DoctorException,
|
||||
NetworkError, LoginResponse, SendMagicLinkResponse, LogoutResponse,
|
||||
GetCurrentUserResponse, RequestPasswordResetResponse, CreateUserWithPasswordResponse,
|
||||
HardDeleteUserResponse, RegisterPatientResponse, GetAvailableSlotsResponse,
|
||||
CreateAppointmentResponse, CancelAppointmentResponse, CreateReportResponse,
|
||||
UpdateReportResponse, ListResponse
|
||||
} from './types';
|
||||
|
||||
// Ação Futura: Mover estas chaves para variáveis de ambiente.
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co';
|
||||
const API_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ';
|
||||
|
||||
/**
|
||||
* Cliente de API base que retorna uma união discriminada para todos os cenários.
|
||||
*/
|
||||
async function apiClient<T>(endpoint: string, options: RequestInit = {}, isPublic: boolean = false): Promise<{ status: number; data: T } | NetworkError> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('apikey', API_KEY);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
|
||||
if (!isPublic) {
|
||||
try {
|
||||
const authSession = localStorage.getItem('supabase.auth.token');
|
||||
if (authSession) {
|
||||
const session = JSON.parse(authSession);
|
||||
// A estrutura do token pode variar, ajuste se necessário
|
||||
const token = session?.currentSession?.access_token || session?.access_token;
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Falha ao ler o token de autenticação do localStorage.", error);
|
||||
}
|
||||
}
|
||||
|
||||
const config: RequestInit = { ...options, headers };
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (response.status === 204) {
|
||||
return { status: response.status, data: undefined as T };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: data as T,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { status: 'network_error', error };
|
||||
}
|
||||
return { status: 'network_error', error: new Error('Erro de rede desconhecido') };
|
||||
}
|
||||
}
|
||||
|
||||
// V4 CHANGE: Adicionamos uma asserção de tipo `as Promise<...>` em cada função.
|
||||
// Isso informa ao TypeScript que confiamos que o retorno do apiClient corresponderá
|
||||
// à união discriminada específica que definimos para cada endpoint.
|
||||
|
||||
// --- SERVIÇOS DE AUTENTICAÇÃO ---
|
||||
export const authService = {
|
||||
login: (credentials: { email: string; password: string }): Promise<LoginResponse> => {
|
||||
return apiClient('/auth/v1/token?grant_type=password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
}, true) as Promise<LoginResponse>;
|
||||
},
|
||||
sendMagicLink: (email: string): Promise<SendMagicLinkResponse> => {
|
||||
return apiClient('/auth/v1/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}, true) as Promise<SendMagicLinkResponse>;
|
||||
},
|
||||
logout: (): Promise<LogoutResponse> => {
|
||||
return apiClient('/auth/v1/logout', { method: 'POST' }) as Promise<LogoutResponse>;
|
||||
},
|
||||
getCurrentUser: (): Promise<GetCurrentUserResponse> => {
|
||||
return apiClient('/functions/v1/user-info', { method: 'POST' }) as Promise<GetCurrentUserResponse>;
|
||||
},
|
||||
};
|
||||
|
||||
// --- SERVIÇOS DE USUÁRIOS ---
|
||||
export const userService = {
|
||||
requestPasswordReset: (email: string, redirectUrl?: string): Promise<RequestPasswordResetResponse> => {
|
||||
return apiClient('/request-password-reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, redirect_url: redirectUrl }),
|
||||
}, true) as Promise<RequestPasswordResetResponse>;
|
||||
},
|
||||
createUserWithPassword: (payload: object): Promise<CreateUserWithPasswordResponse> => {
|
||||
return apiClient('/create-user-with-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}) as Promise<CreateUserWithPasswordResponse>;
|
||||
},
|
||||
hardDeleteUser_DANGEROUS: (userId: string): Promise<HardDeleteUserResponse> => {
|
||||
return apiClient('/delete-user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
}) as Promise<HardDeleteUserResponse>;
|
||||
},
|
||||
deactivateUser: (userId: string): Promise<any> => {
|
||||
return apiClient(`/rest/v1/profiles?id=eq.${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify({ disabled: true }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// --- SERVIÇOS DE PACIENTES ---
|
||||
export const patientService = {
|
||||
registerPatient: (payload: { email: string; full_name: string; phone_mobile: string; cpf: string; birth_date?: string }): Promise<RegisterPatientResponse> => {
|
||||
return apiClient('/functions/v1/register-patient', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}, true) as Promise<RegisterPatientResponse>;
|
||||
},
|
||||
list: (filters: { fullName?: string; cpf?: string; limit?: number; offset?: number } = {}): Promise<ListResponse<Patient>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.fullName) query.set('full_name', `ilike.*${filters.fullName}*`);
|
||||
if (filters.cpf) query.set('cpf', `eq.${filters.cpf}`);
|
||||
if (filters.limit) query.set('limit', String(filters.limit));
|
||||
if (filters.offset) query.set('offset', String(filters.offset));
|
||||
return apiClient(`/rest/v1/patients?${query.toString()}`) as Promise<ListResponse<Patient>>;
|
||||
},
|
||||
create: (payload: Omit<Patient, 'id' | 'created_at' | 'updated_at'>): Promise<any> => {
|
||||
return apiClient('/rest/v1/patients', {
|
||||
method: 'POST',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// --- SERVIÇOS DE MÉDICOS ---
|
||||
export const doctorService = {
|
||||
list: (filters: { specialty?: string; active?: boolean } = {}): Promise<ListResponse<Doctor>> => {
|
||||
const query = new URLSearchParams({ select: '*' });
|
||||
if (filters.specialty) query.set('specialty', `eq.${filters.specialty}`);
|
||||
if (filters.active !== undefined) query.set('active', `eq.${filters.active}`);
|
||||
return apiClient(`/rest/v1/doctors?${query.toString()}`) as Promise<ListResponse<Doctor>>;
|
||||
},
|
||||
};
|
||||
|
||||
// --- SERVIÇOS DE AGENDAMENTO E DISPONIBILIDADE ---
|
||||
export const scheduleService = {
|
||||
getAvailableSlots: (doctorId: string, date: string): Promise<GetAvailableSlotsResponse> => {
|
||||
return apiClient('/functions/v1/get-available-slots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ doctor_id: doctorId, date }),
|
||||
}) as Promise<GetAvailableSlotsResponse>;
|
||||
},
|
||||
createAppointment: (payload: NewAppointmentPayload): Promise<CreateAppointmentResponse> => {
|
||||
return apiClient('/rest/v1/appointments', {
|
||||
method: 'POST',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
}) as Promise<CreateAppointmentResponse>;
|
||||
},
|
||||
listAppointments: (filters: { doctorId?: string; patientId?: string; status?: string }): Promise<ListResponse<Appointment>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`);
|
||||
if (filters.patientId) query.set('patient_id', `eq.${filters.patientId}`);
|
||||
if (filters.status) query.set('status', `eq.${filters.status}`);
|
||||
return apiClient(`/rest/v1/appointments?${query.toString()}`) as Promise<ListResponse<Appointment>>;
|
||||
},
|
||||
cancelAppointment: (appointmentId: string, reason: string): Promise<CancelAppointmentResponse> => {
|
||||
return apiClient(`/rest/v1/appointments?id=eq.${appointmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify({
|
||||
status: 'cancelled',
|
||||
cancellation_reason: reason,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
}),
|
||||
}) as Promise<CancelAppointmentResponse>;
|
||||
},
|
||||
listAvailability: (filters: { doctorId?: string } = {}): Promise<ListResponse<DoctorAvailability>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`);
|
||||
return apiClient(`/rest/v1/doctor_availability?${query.toString()}`) as Promise<ListResponse<DoctorAvailability>>;
|
||||
},
|
||||
listExceptions: (filters: { doctorId?: string; date?: string } = {}): Promise<ListResponse<DoctorException>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`);
|
||||
if (filters.date) query.set('date', `eq.${filters.date}`);
|
||||
return apiClient(`/rest/v1/doctor_exceptions?${query.toString()}`) as Promise<ListResponse<DoctorException>>;
|
||||
},
|
||||
};
|
||||
|
||||
// --- SERVIÇOS DE LAUDOS (REPORTS) ---
|
||||
export const reportService = {
|
||||
list: (filters: { patientId?: string; createdBy?: string }): Promise<ListResponse<Report>> => {
|
||||
const query = new URLSearchParams({ order: 'created_at.desc' });
|
||||
if (filters.patientId) query.set('patient_id', `eq.${filters.patientId}`);
|
||||
if (filters.createdBy) query.set('created_by', `eq.${filters.createdBy}`);
|
||||
return apiClient(`/rest/v1/reports?${query.toString()}`) as Promise<ListResponse<Report>>;
|
||||
},
|
||||
create: (payload: ReportInput): Promise<CreateReportResponse> => {
|
||||
return apiClient('/rest/v1/reports', {
|
||||
method: 'POST',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
}) as Promise<CreateReportResponse>;
|
||||
},
|
||||
update: (reportId: string, payload: Partial<ReportInput>): Promise<UpdateReportResponse> => {
|
||||
return apiClient(`/rest/v1/reports?id=eq.${reportId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
}) as Promise<UpdateReportResponse>;
|
||||
},
|
||||
};
|
||||
61
services/api/apiTestData.ts
Normal file
61
services/api/apiTestData.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// services/api/apiTestData.ts
|
||||
|
||||
/**
|
||||
* Este arquivo centraliza todos os payloads de teste para o ApiVerificationPage.
|
||||
* Cada objeto exportado contém cenários de sucesso e de erro para uma função específica.
|
||||
*/
|
||||
|
||||
// --- Autenticação ---
|
||||
export const loginTestData = {
|
||||
success: { email: 'riseup@popcode.com.br', password: 'riseup' },
|
||||
error: { email: 'erro@popcode.com.br', password: 'senhaerrada' },
|
||||
};
|
||||
|
||||
export const magicLinkTestData = {
|
||||
success: { email: 'gabriel.doria@popcode.com.br' }, // Use um email real que você possa verificar
|
||||
error: { email: 'emailinvalido' },
|
||||
};
|
||||
|
||||
// --- Usuários ---
|
||||
export const resetPassTestData = {
|
||||
success: { email: 'gabriel.doria@popcode.com.br', redirectUrl: '' },
|
||||
error: { email: 'emailinvalido', redirectUrl: '' },
|
||||
};
|
||||
|
||||
export const deleteUserTestData = {
|
||||
success: { userId: 'uuid-de-um-usuario-para-deletar' }, // Substitua por um UUID real para testar
|
||||
error: { userId: 'uuid-invalido' },
|
||||
};
|
||||
|
||||
// --- Pacientes ---
|
||||
export const registerPatientTestData = {
|
||||
success: {
|
||||
email: `paciente_${Date.now()}@teste.com`,
|
||||
full_name: 'Paciente de Teste Válido',
|
||||
phone_mobile: '11987654321',
|
||||
cpf: '12345678901', // A API valida o formato, não a existência real
|
||||
},
|
||||
errorValidation: {
|
||||
email: 'emailinvalido',
|
||||
full_name: 'AB', // Nome curto
|
||||
phone_mobile: '123', // Telefone curto
|
||||
cpf: '111', // CPF curto
|
||||
},
|
||||
errorConflict: {
|
||||
email: 'paciente_existente@teste.com', // Use um email que já exista no seu banco
|
||||
full_name: 'Paciente Conflitante',
|
||||
phone_mobile: '11987654321',
|
||||
cpf: '11111111111', // Use um CPF que já exista
|
||||
},
|
||||
};
|
||||
|
||||
export const listPatientsTestData = {
|
||||
success: { fullName: 'Silva', limit: 5 },
|
||||
noFilter: {},
|
||||
};
|
||||
|
||||
// --- Agendamentos ---
|
||||
export const slotsTestData = {
|
||||
success: { doctorId: 'uuid-de-um-medico-real', date: '2025-10-25' }, // Substitua pelo UUID de um médico
|
||||
error: { doctorId: 'uuid-invalido', date: '2025-10-25' },
|
||||
};
|
||||
194
services/api/types.ts
Normal file
194
services/api/types.ts
Normal file
@ -0,0 +1,194 @@
|
||||
// types.ts (V3)
|
||||
|
||||
// --- TIPOS DE MODELO DE DADOS (Schemas) ---
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone?: string;
|
||||
roles: ('admin' | 'gestor' | 'medico' | 'secretaria' | 'paciente')[];
|
||||
}
|
||||
|
||||
export interface Patient {
|
||||
id: string;
|
||||
full_name: string;
|
||||
cpf: string;
|
||||
email: string;
|
||||
phone_mobile: string;
|
||||
birth_date?: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Doctor {
|
||||
id: string;
|
||||
full_name: string;
|
||||
crm: string;
|
||||
crm_uf: string;
|
||||
specialty?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
patient_id: string;
|
||||
scheduled_at: string;
|
||||
duration_minutes: number;
|
||||
status: 'requested' | 'confirmed' | 'completed' | 'cancelled';
|
||||
created_by: string;
|
||||
cancelled_at?: string;
|
||||
cancellation_reason?: string;
|
||||
}
|
||||
|
||||
export type NewAppointmentPayload = Omit<Appointment, 'id' | 'status'> & {
|
||||
status?: 'requested' | 'confirmed';
|
||||
};
|
||||
|
||||
export interface AvailableSlot {
|
||||
time: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
order_number: string;
|
||||
patient_id: string;
|
||||
status: 'draft' | 'completed';
|
||||
exam?: string;
|
||||
requested_by?: string;
|
||||
cid_code?: string;
|
||||
diagnosis?: string;
|
||||
conclusion?: string;
|
||||
content_html?: string;
|
||||
content_json?: object;
|
||||
hide_date: boolean;
|
||||
hide_signature: boolean;
|
||||
due_at?: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ReportInput = Partial<Omit<Report, 'id' | 'order_number' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'>> & {
|
||||
patient_id: string;
|
||||
};
|
||||
|
||||
export interface DoctorAvailability {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
slot_minutes: number;
|
||||
appointment_type: 'presencial' | 'telemedicina';
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorException {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
kind: 'bloqueio' | 'disponibilidade_extra';
|
||||
start_time?: string | null;
|
||||
end_time?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// --- TIPOS DE RESPOSTA DA API (UNIÕES DISCRIMINADAS) ---
|
||||
|
||||
/**
|
||||
* Erro genérico para falhas de rede (CORS, offline, etc.).
|
||||
*/
|
||||
export type NetworkError = { status: 'network_error'; error: Error };
|
||||
|
||||
/**
|
||||
* Tipo base para respostas de erro da API com um corpo JSON.
|
||||
*/
|
||||
export type ApiErrorResponse<Status extends number, Data> = { status: Status; data: Data };
|
||||
|
||||
// Respostas para: authService.login
|
||||
export type LoginSuccess = { status: 200; data: AuthResponse };
|
||||
export type LoginError400 = ApiErrorResponse<400, { error: string; error_description: string }>;
|
||||
export type LoginResponse = LoginSuccess | LoginError400 | NetworkError;
|
||||
|
||||
// Respostas para: authService.sendMagicLink
|
||||
export type SendMagicLinkSuccess = { status: 200; data: object };
|
||||
export type SendMagicLinkError429 = ApiErrorResponse<429, { message: string }>;
|
||||
export type SendMagicLinkResponse = SendMagicLinkSuccess | SendMagicLinkError429 | NetworkError;
|
||||
|
||||
// Respostas para: authService.logout
|
||||
export type LogoutSuccess = { status: 204; data: undefined };
|
||||
export type LogoutError401 = ApiErrorResponse<401, { message: string }>;
|
||||
export type LogoutResponse = LogoutSuccess | LogoutError401 | NetworkError;
|
||||
|
||||
// Respostas para: authService.getCurrentUser
|
||||
export type GetCurrentUserSuccess = { status: 200; data: UserProfile };
|
||||
export type GetCurrentUserError401 = ApiErrorResponse<401, { message: string }>;
|
||||
export type GetCurrentUserResponse = GetCurrentUserSuccess | GetCurrentUserError401 | NetworkError;
|
||||
|
||||
// Respostas para: userService.requestPasswordReset
|
||||
export type RequestPasswordResetSuccess = { status: 200; data: { success: boolean; message: string } };
|
||||
export type RequestPasswordResetError400 = ApiErrorResponse<400, { detail: string }>;
|
||||
export type RequestPasswordResetResponse = RequestPasswordResetSuccess | RequestPasswordResetError400 | NetworkError;
|
||||
|
||||
// Respostas para: userService.createUserWithPassword
|
||||
export type CreateUserWithPasswordSuccess = { status: 201; data: { success: boolean; user: UserProfile; patient_id?: string } };
|
||||
export type CreateUserWithPasswordError400 = ApiErrorResponse<400, { error: string }>;
|
||||
export type CreateUserWithPasswordError403 = ApiErrorResponse<403, { error: string }>;
|
||||
export type CreateUserWithPasswordResponse = CreateUserWithPasswordSuccess | CreateUserWithPasswordError400 | CreateUserWithPasswordError403 | NetworkError;
|
||||
|
||||
// Respostas para: userService.hardDeleteUser_DANGEROUS
|
||||
export type HardDeleteUserSuccess = { status: 200; data: { success: boolean; message: string; userId: string } };
|
||||
export type HardDeleteUserError400 = ApiErrorResponse<400, { error: string }>;
|
||||
export type HardDeleteUserError403 = ApiErrorResponse<403, { error: string }>;
|
||||
export type HardDeleteUserResponse = HardDeleteUserSuccess | HardDeleteUserError400 | HardDeleteUserError403 | NetworkError;
|
||||
|
||||
// Respostas para: patientService.registerPatient
|
||||
export type RegisterPatientSuccess = { status: 200; data: { success: boolean; patient_id: string; message: string } };
|
||||
export type RegisterPatientError400 = ApiErrorResponse<400, { error: string; code: 'VALIDATION_ERROR'; details?: any[] }>;
|
||||
export type RegisterPatientError409 = ApiErrorResponse<409, { error: string; code: 'CPF_EXISTS' | 'EMAIL_EXISTS' }>;
|
||||
export type RegisterPatientError429 = ApiErrorResponse<429, { error: string; code: 'RATE_LIMIT_EXCEEDED' }>;
|
||||
export type RegisterPatientError500 = ApiErrorResponse<500, { error: string; code: string }>;
|
||||
export type RegisterPatientResponse = RegisterPatientSuccess | RegisterPatientError400 | RegisterPatientError409 | RegisterPatientError429 | RegisterPatientError500 | NetworkError;
|
||||
|
||||
// Respostas para: scheduleService.getAvailableSlots
|
||||
export type GetAvailableSlotsSuccess = { status: 200; data: { slots: AvailableSlot[] } };
|
||||
export type GetAvailableSlotsResponse = GetAvailableSlotsSuccess | NetworkError;
|
||||
|
||||
// Respostas para: scheduleService.createAppointment
|
||||
export type CreateAppointmentSuccess = { status: 201; data: Appointment };
|
||||
export type CreateAppointmentResponse = CreateAppointmentSuccess | NetworkError;
|
||||
|
||||
// Respostas para: scheduleService.cancelAppointment
|
||||
export type CancelAppointmentSuccess = { status: 200; data: Appointment };
|
||||
export type CancelAppointmentResponse = CancelAppointmentSuccess | NetworkError;
|
||||
|
||||
// Respostas para: reportService.create
|
||||
export type CreateReportSuccess = { status: 201; data: Report };
|
||||
export type CreateReportResponse = CreateReportSuccess | NetworkError;
|
||||
|
||||
// Respostas para: reportService.update
|
||||
export type UpdateReportSuccess = { status: 200; data: Report };
|
||||
export type UpdateReportResponse = UpdateReportSuccess | NetworkError;
|
||||
|
||||
// Tipos genéricos para listagens simples
|
||||
export type ListSuccess<T> = { status: 200; data: T[] };
|
||||
export type ListResponse<T> = ListSuccess<T> | NetworkError;
|
||||
@ -1,45 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const appointmentsService = {
|
||||
/**
|
||||
* Busca por horários disponíveis para agendamento.
|
||||
* @param {object} data - Critérios da busca (ex: { doctor_id, date }).
|
||||
* @returns {Promise<Array>} - Uma promessa que resolve para uma lista de horários disponíveis.
|
||||
*/
|
||||
search_h: (data) => api.post('/functions/v1/get-available-slots', data),
|
||||
|
||||
/**
|
||||
* Lista todos os agendamentos.
|
||||
* @returns {Promise<Array>} - Uma promessa que resolve para a lista de agendamentos.
|
||||
*/
|
||||
list: () => api.get('/rest/v1/appointments'),
|
||||
|
||||
/**
|
||||
* Cria um novo agendamento.
|
||||
* @param {object} data - Os dados do agendamento a ser criado.
|
||||
* @returns {Promise<object>} - Uma promessa que resolve para o agendamento criado.
|
||||
*/
|
||||
create: (data) => api.post('/rest/v1/appointments', data),
|
||||
|
||||
/**
|
||||
* Busca agendamentos com base em parâmetros de consulta.
|
||||
* @param {string} queryParams - A string de consulta (ex: 'patient_id=eq.123&status=eq.scheduled').
|
||||
* @returns {Promise<Array>} - Uma promessa que resolve para a lista de agendamentos encontrados.
|
||||
*/
|
||||
search_appointment: (queryParams) => api.get(`/rest/v1/appointments?${queryParams}`),
|
||||
|
||||
/**
|
||||
* Atualiza um agendamento existente.
|
||||
* @param {string|number} id - O ID do agendamento a ser atualizado.
|
||||
* @param {object} data - Os novos dados para o agendamento.
|
||||
* @returns {Promise<object>} - Uma promessa que resolve com a resposta da API.
|
||||
*/
|
||||
update: (id, data) => api.patch(`/rest/v1/appointments?id=eq.${id}`, data),
|
||||
|
||||
/**
|
||||
* Deleta um agendamento.
|
||||
* @param {string|number} id - O ID do agendamento a ser deletado.
|
||||
* @returns {Promise<object>} - Uma promessa que resolve com a resposta da API.
|
||||
*/
|
||||
delete: (id) => api.delete(`/rest/v1/appointments?id=eq.${id}`),
|
||||
};
|
||||
24
services/atribuicoesApi.ts
Normal file
24
services/atribuicoesApi.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Caminho: services/atribuicoesApi.ts
|
||||
import api from './api';
|
||||
|
||||
export interface PatientAssignment {
|
||||
id: any;
|
||||
patient_id: string;
|
||||
user_id: string;
|
||||
role: 'medico' | 'enfermeiro';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const atribuicoesApi = {
|
||||
list: async (): Promise<PatientAssignment[]> => {
|
||||
const response = await api.get<PatientAssignment[]>('/rest/v1/patient_assignments');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: Omit<PatientAssignment, 'id' | 'created_at'>): Promise<PatientAssignment> => {
|
||||
const response = await api.post<PatientAssignment[]>('/rest/v1/patient_assignments', data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
};
|
||||
80
services/autenticacaoApi.ts
Normal file
80
services/autenticacaoApi.ts
Normal file
@ -0,0 +1,80 @@
|
||||
// Caminho: services/autenticacaoApi.ts
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const baseURL = 'https://yuanqfswhberkoevtmfr.supabase.co';
|
||||
const apiKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ';
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: any;
|
||||
// Adicione outros campos conforme necessário
|
||||
}
|
||||
|
||||
export const autenticacaoApi = {
|
||||
loginWithEmailAndPassword: async (email: string, password: string): Promise<LoginResponse> => {
|
||||
const response = await fetch(`${baseURL}/auth/v1/token?grant_type=password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_description || 'Falha no login');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
// Armazena o token nos cookies
|
||||
Cookies.set('supabase-token', data.access_token, { expires: 1, path: '/' });
|
||||
return data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
const token = Cookies.get('supabase-token');
|
||||
await fetch(`${baseURL}/auth/v1/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': apiKey,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
// Remove o token dos cookies
|
||||
Cookies.remove('supabase-token', { path: '/' });
|
||||
},
|
||||
|
||||
sendMagicLink: async (email: string, redirectTo?: string): Promise<any> => {
|
||||
const response = await fetch(`${baseURL}/auth/v1/otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, options: { emailRedirectTo: redirectTo } }),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
renewToken: async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const response = await fetch(`${baseURL}/auth/v1/token?grant_type=refresh_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_description || 'Falha ao renovar token');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
Cookies.set('supabase-token', data.access_token, { expires: 1, path: '/' });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const AvailabilityService = {
|
||||
list: () => api.get("/rest/v1/doctor_availability"),
|
||||
listById: (id) => api.get(`/rest/v1/doctor_availability?doctor_id=eq.${id}`),
|
||||
create: (data) => api.post("/rest/v1/doctor_availability", data),
|
||||
update: (id, data) => api.patch(`/rest/v1/doctor_availability?id=eq.${id}`, data),
|
||||
delete: (id) => api.delete(`/rest/v1/doctor_availability?id=eq.${id}`),
|
||||
};
|
||||
27
services/avatarsApi.ts
Normal file
27
services/avatarsApi.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// Caminho: services/avatarsApi.ts
|
||||
import api from './api';
|
||||
|
||||
const baseURL = 'https://yuanqfswhberkoevtmfr.supabase.co';
|
||||
|
||||
export const avatarsApi = {
|
||||
upload: async (userId: string, file: File): Promise<any> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post(`/storage/v1/object/avatars/${userId}/avatar`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
remove: async (userId: string): Promise<any> => {
|
||||
const response = await api.delete(`/storage/v1/object/avatars/${userId}/avatar`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPublicUrl: (userId: string, ext: 'jpg' | 'png' | 'webp'): string => {
|
||||
return `${baseURL}/storage/v1/object/public/avatars/${userId}/avatar.${ext}`;
|
||||
},
|
||||
};
|
||||
119
services/disponibilidadeApi.ts
Normal file
119
services/disponibilidadeApi.ts
Normal file
@ -0,0 +1,119 @@
|
||||
// Caminho: services/disponibilidadeApi.ts (Completo e Corrigido)
|
||||
|
||||
import api from './api';
|
||||
|
||||
// --- Tipagem para Disponibilidade Semanal ---
|
||||
|
||||
export interface DoctorAvailability {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
slot_minutes: number;
|
||||
appointment_type: 'presencial' | 'telemedicina';
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
export type CreateDoctorAvailabilityData = Omit<DoctorAvailability, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type UpdateDoctorAvailabilityData = Partial<Omit<DoctorAvailability, 'id' | 'created_at' | 'updated_at' | 'created_by'>>;
|
||||
|
||||
interface ListAvailabilityParams {
|
||||
doctor_id?: string;
|
||||
weekday?: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// --- Tipagem para Exceções de Agenda ---
|
||||
|
||||
export interface DoctorException {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
kind: 'bloqueio' | 'disponibilidade_extra';
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
reason?: string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
export type CreateDoctorExceptionData = Omit<DoctorException, 'id' | 'created_at'>;
|
||||
|
||||
interface ListExceptionParams {
|
||||
doctor_id?: string;
|
||||
date?: string;
|
||||
kind?: 'bloqueio' | 'disponibilidade_extra';
|
||||
}
|
||||
|
||||
// --- Objeto do Serviço de API ---
|
||||
|
||||
export const disponibilidadeApi = {
|
||||
// --- Métodos para Disponibilidade Semanal ---
|
||||
|
||||
list: async (params: ListAvailabilityParams = {}): Promise<DoctorAvailability[]> => {
|
||||
// CORREÇÃO: Formata os parâmetros para o padrão do Supabase
|
||||
const queryParams: { [key: string]: string } = { select: '*' };
|
||||
if (params.doctor_id) queryParams.doctor_id = `eq.${params.doctor_id}`;
|
||||
if (params.weekday !== undefined) queryParams.weekday = `eq.${params.weekday}`;
|
||||
if (params.active !== undefined) queryParams.active = `eq.${params.active}`;
|
||||
|
||||
const response = await api.get<DoctorAvailability[]>('/rest/v1/doctor_availability', {
|
||||
params: queryParams,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<DoctorAvailability> => {
|
||||
const response = await api.get<DoctorAvailability[]>(`/rest/v1/doctor_availability?id=eq.${id}&select=*`);
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
throw new Error("Registro de disponibilidade não encontrado.");
|
||||
},
|
||||
|
||||
create: async (data: CreateDoctorAvailabilityData): Promise<DoctorAvailability> => {
|
||||
const response = await api.post<DoctorAvailability[]>('/rest/v1/doctor_availability', data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateDoctorAvailabilityData): Promise<DoctorAvailability> => {
|
||||
const response = await api.patch<DoctorAvailability[]>(`/rest/v1/doctor_availability?id=eq.${id}`, data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/rest/v1/doctor_availability?id=eq.${id}`);
|
||||
},
|
||||
|
||||
// --- Métodos para Exceções de Agenda ---
|
||||
|
||||
listExceptions: async (params: ListExceptionParams = {}): Promise<DoctorException[]> => {
|
||||
// CORREÇÃO: Formata os parâmetros para o padrão do Supabase
|
||||
const queryParams: { [key: string]: string } = { select: '*' };
|
||||
if (params.doctor_id) queryParams.doctor_id = `eq.${params.doctor_id}`;
|
||||
if (params.date) queryParams.date = `eq.${params.date}`;
|
||||
if (params.kind) queryParams.kind = `eq.${params.kind}`;
|
||||
|
||||
const response = await api.get<DoctorException[]>('/rest/v1/doctor_exceptions', {
|
||||
params: queryParams,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createException: async (data: CreateDoctorExceptionData): Promise<DoctorException> => {
|
||||
const response = await api.post<DoctorException[]>('/rest/v1/doctor_exceptions', data, {
|
||||
headers: {
|
||||
'Prefer': 'return=representation',
|
||||
},
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const doctorsService = {
|
||||
list: () => api.get("/rest/v1/doctors"),
|
||||
getById: (id) => api.get(`/rest/v1/doctors?id=eq.${id}`).then(data => data[0]),
|
||||
create: (data) => api.post("/rest/v1/doctors", data),
|
||||
update: (id, data) => api.patch(`/rest/v1/doctors?id=eq.${id}`, data),
|
||||
delete: (id) => api.delete(`/rest/v1/doctors?id=eq.${id}`),
|
||||
};
|
||||
28
services/excecoesApi.ts
Normal file
28
services/excecoesApi.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Caminho: services/excecoesApi.ts
|
||||
import api from './api';
|
||||
|
||||
export interface Exception {
|
||||
id: any;
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
kind: 'bloqueio' | 'liberacao';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const excecoesApi = {
|
||||
list: async (): Promise<Exception[]> => {
|
||||
const response = await api.get<Exception[]>('/rest/v1/doctor_exceptions?select=*');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: Omit<Exception, 'id'>): Promise<Exception> => {
|
||||
const response = await api.post<Exception[]>('/rest/v1/doctor_exceptions', data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/rest/v1/doctor_exceptions?id=eq.${id}`);
|
||||
},
|
||||
};
|
||||
@ -1,8 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const exceptionsService = {
|
||||
list: () => api.get("/rest/v1/doctor_exceptions"),
|
||||
listById: () => api.get(`/rest/v1/doctor_exceptions?id=eq.${id}`),
|
||||
create: (data) => api.post("/rest/v1/doctor_exceptions", data),
|
||||
delete: (id) => api.delete(`/rest/v1/doctor_exceptions?id=eq.${id}`),
|
||||
};
|
||||
87
services/medicosApi.ts
Normal file
87
services/medicosApi.ts
Normal file
@ -0,0 +1,87 @@
|
||||
// Caminho: services/medicosApi.ts (Corrigido)
|
||||
|
||||
import api from './api';
|
||||
|
||||
export interface Doctor {
|
||||
id: string;
|
||||
full_name: string;
|
||||
crm: string;
|
||||
crm_uf: string;
|
||||
specialty: string;
|
||||
active?: boolean;
|
||||
location?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CreateDoctorData {
|
||||
email: string;
|
||||
full_name: string;
|
||||
cpf: string;
|
||||
crm: string;
|
||||
crm_uf: string;
|
||||
specialty?: string;
|
||||
phone_mobile?: string;
|
||||
}
|
||||
|
||||
interface ListParams {
|
||||
active?: boolean;
|
||||
specialty?: string;
|
||||
}
|
||||
|
||||
export const medicosApi = {
|
||||
/**
|
||||
* Lista médicos com filtros opcionais.
|
||||
* @param params - Objeto com filtros como { active: true }
|
||||
*/
|
||||
list: async (params: ListParams = {}): Promise<Doctor[]> => {
|
||||
// Prepara os parâmetros para o formato do PostgREST
|
||||
const queryParams: { [key: string]: any } = {
|
||||
select: '*',
|
||||
};
|
||||
|
||||
if (params.active !== undefined) {
|
||||
queryParams.active = `eq.${params.active}`; // CORREÇÃO: Usa 'eq.true' ou 'eq.false'
|
||||
}
|
||||
if (params.specialty) {
|
||||
queryParams.specialty = `eq.${params.specialty}`;
|
||||
}
|
||||
|
||||
const response = await api.get<Doctor[]>('/rest/v1/doctors', {
|
||||
params: queryParams,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cria um novo médico com validações através da Edge Function.
|
||||
* @param data - Dados do médico a ser criado.
|
||||
*/
|
||||
createWithValidation: async (data: CreateDoctorData): Promise<any> => {
|
||||
const response = await api.post('/functions/v1/create-doctor', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Doctor> => {
|
||||
const response = await api.get<Doctor[]>(`/rest/v1/doctors?id=eq.${id}&select=*`);
|
||||
if (response.data.length === 0) {
|
||||
throw new Error("Médico não encontrado");
|
||||
}
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<Doctor>): Promise<Doctor> => {
|
||||
const response = await api.patch<Doctor[]>(`/rest/v1/doctors?id=eq.${id}`, data, {
|
||||
headers: { 'Prefer': 'return=representation' },
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Busca uma lista de médicos de exemplo do endpoint de mock.
|
||||
* Usado como fallback quando a API real não retorna dados.
|
||||
*/
|
||||
getMockDoctors: async (): Promise<Doctor[]> => {
|
||||
const response = await api.get<Doctor[]>('https://mock.apidog.com/m1/1053378-0-default/rest/v1/doctors');
|
||||
return Array.isArray(response.data) ? response.data : [response.data];
|
||||
},
|
||||
};
|
||||
92
services/pacientesApi.ts
Normal file
92
services/pacientesApi.ts
Normal file
@ -0,0 +1,92 @@
|
||||
// Caminho: services/pacientesApi.ts (Completo e Corrigido)
|
||||
|
||||
import api from './api';
|
||||
|
||||
export interface Patient {
|
||||
id: string;
|
||||
nome_completo: string;
|
||||
cpf: string;
|
||||
email?: string;
|
||||
telefone?: string;
|
||||
data_nascimento?: string;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
cep?: string;
|
||||
convenio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type CreatePatientData = Omit<Patient, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type UpdatePatientData = Partial<CreatePatientData>;
|
||||
|
||||
interface ListParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order?: string;
|
||||
nome_completo?: string;
|
||||
cpf?: string;
|
||||
}
|
||||
|
||||
export const pacientesApi = {
|
||||
list: async (params: ListParams = {}): Promise<Patient[]> => {
|
||||
const response = await api.get<Patient[]>('/rest/v1/patients', {
|
||||
params: {
|
||||
select: '*',
|
||||
...params,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Busca um único paciente pelo seu ID.
|
||||
* @param id - O UUID do paciente.
|
||||
*/
|
||||
getById: async (id: string): Promise<Patient> => {
|
||||
const response = await api.get<Patient[]>(`/rest/v1/patients`, {
|
||||
params: {
|
||||
id: `eq.${id}`,
|
||||
select: '*',
|
||||
},
|
||||
// O header 'Accept' foi REMOVIDO para evitar o erro 406.
|
||||
// A resposta será um array, então pegamos o primeiro item.
|
||||
});
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
throw new Error("Paciente não encontrado");
|
||||
},
|
||||
|
||||
create: async (data: CreatePatientData): Promise<Patient> => {
|
||||
const response = await api.post<Patient[]>('/rest/v1/patients', data, {
|
||||
headers: {
|
||||
'Prefer': 'return=representation',
|
||||
}
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdatePatientData): Promise<Patient> => {
|
||||
const response = await api.patch<Patient[]>(`/rest/v1/patients?id=eq.${id}`, data, {
|
||||
headers: {
|
||||
'Prefer': 'return=representation',
|
||||
}
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/rest/v1/patients?id=eq.${id}`);
|
||||
},
|
||||
getMockPatient: async (): Promise<Patient> => {
|
||||
// Usamos uma instância separada do axios ou o `api.get` com a URL completa
|
||||
// para chamar um domínio diferente.
|
||||
const response = await api.get<Patient>('https://mock.apidog.com/m1/1053378-0-default/rest/v1/patients');
|
||||
// Assumindo que o mock retorna um array, pegamos o primeiro item.
|
||||
// Se retornar um objeto direto, seria apenas `response.data`.
|
||||
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
},
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const patientsService = {
|
||||
list: () => api.get("/rest/v1/patients"),
|
||||
getById: (id) => api.get(`/rest/v1/patients?id=eq.${id}`),
|
||||
create: (data) => api.post("/rest/v1/patients", data),
|
||||
update: (id, data) => api.patch(`/rest/v1/patients?id=eq.${id}`, data),
|
||||
delete: (id) => api.delete(`/rest/v1/patients?id=eq.${id}`),
|
||||
};
|
||||
23
services/perfisApi.ts
Normal file
23
services/perfisApi.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// Caminho: services/perfisApi.ts
|
||||
import api from './api';
|
||||
|
||||
export interface Profile {
|
||||
id: any;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const perfisApi = {
|
||||
list: async (): Promise<Profile[]> => {
|
||||
const response = await api.get<Profile[]>('/rest/v1/profiles?select=*');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (userId: string, data: Partial<Profile>): Promise<Profile> => {
|
||||
const response = await api.patch<Profile[]>(`/rest/v1/profiles?id=eq.${userId}`, data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
};
|
||||
41
services/relatoriosApi.ts
Normal file
41
services/relatoriosApi.ts
Normal file
@ -0,0 +1,41 @@
|
||||
// Caminho: services/relatoriosApi.ts
|
||||
import api from './api';
|
||||
|
||||
export interface Report {
|
||||
id: any;
|
||||
patient_id: string;
|
||||
exam: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const relatoriosApi = {
|
||||
list: async (): Promise<Report[]> => {
|
||||
const response = await api.get<Report[]>('/rest/v1/reports?select=*');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Report> => {
|
||||
const response = await api.get<Report>(`/rest/v1/reports?id=eq.${id}&select=*`, {
|
||||
headers: { Accept: 'application/vnd.pgrst.object+json' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: Omit<Report, 'id'>): Promise<Report> => {
|
||||
const response = await api.post<Report[]>('/rest/v1/reports', data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<Report>): Promise<Report> => {
|
||||
const response = await api.patch<Report[]>(`/rest/v1/reports?id=eq.${id}`, data, {
|
||||
headers: { 'Prefer': 'return=representation' }
|
||||
});
|
||||
return response.data[0];
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/rest/v1/reports?id=eq.${id}`);
|
||||
},
|
||||
};
|
||||
@ -1,42 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
const REPORTS_API_URL = "/rest/v1/reports";
|
||||
|
||||
export const reportsApi = {
|
||||
getReports: async (patientId) => {
|
||||
try {
|
||||
const data = await api.get(`${REPORTS_API_URL}?patient_id=eq.${patientId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reports:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
getReportById: async (reportId) => {
|
||||
try {
|
||||
const data = await api.get(`${REPORTS_API_URL}?id=eq.${reportId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch report ${reportId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createReport: async (reportData) => {
|
||||
try {
|
||||
const data = await api.post(REPORTS_API_URL, reportData);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to create report:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateReport: async (reportId, reportData) => {
|
||||
try {
|
||||
const data = await api.patch(`${REPORTS_API_URL}?id=eq.${reportId}`, reportData);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update report ${reportId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
15
services/smsApi.ts
Normal file
15
services/smsApi.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Caminho: services/smsApi.ts
|
||||
import api from './api';
|
||||
|
||||
interface SmsData {
|
||||
phone_number: string;
|
||||
message: string;
|
||||
patient_id?: string;
|
||||
}
|
||||
|
||||
export const smsApi = {
|
||||
send: async (data: SmsData): Promise<any> => {
|
||||
const response = await api.post('/functions/v1/send-sms', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,55 +0,0 @@
|
||||
import { api } from "./api.mjs";
|
||||
|
||||
export const usersService = {
|
||||
async list_roles() {
|
||||
// continua usando /rest/v1 normalmente
|
||||
return await api.get(`/rest/v1/user_roles?select=id,user_id,role,created_at`);
|
||||
},
|
||||
|
||||
async create_user(data) {
|
||||
// continua usando a Edge Function corretamente
|
||||
return await api.post(`/functions/v1/user-create`, data);
|
||||
},
|
||||
|
||||
// 🚀 Busca dados completos do usuário direto do banco
|
||||
async full_data(user_id) {
|
||||
if (!user_id) throw new Error("user_id é obrigatório");
|
||||
|
||||
// Busca o perfil
|
||||
const [profile] = await api.get(`/rest/v1/profiles?id=eq.${user_id}`);
|
||||
// Busca o papel (role)
|
||||
const [role] = await api.get(`/rest/v1/user_roles?user_id=eq.${user_id}`);
|
||||
// Busca as permissões se existirem em alguma tabela
|
||||
const permissions = {
|
||||
isAdmin: role?.role === "admin",
|
||||
isManager: role?.role === "gestor",
|
||||
isDoctor: role?.role === "medico",
|
||||
isSecretary: role?.role === "secretaria",
|
||||
isAdminOrManager:
|
||||
role?.role === "admin" || role?.role === "gestor" ? true : false,
|
||||
};
|
||||
|
||||
// Monta o objeto no mesmo formato do endpoint `user-info`
|
||||
return {
|
||||
user: {
|
||||
id: user_id,
|
||||
email: profile?.email ?? "—",
|
||||
email_confirmed_at: null,
|
||||
created_at: profile?.created_at ?? "—",
|
||||
last_sign_in_at: null,
|
||||
},
|
||||
profile: {
|
||||
id: profile?.id ?? user_id,
|
||||
full_name: profile?.full_name ?? "—",
|
||||
email: profile?.email ?? "—",
|
||||
phone: profile?.phone ?? "—",
|
||||
avatar_url: profile?.avatar_url ?? null,
|
||||
disabled: profile?.disabled ?? false,
|
||||
created_at: profile?.created_at ?? null,
|
||||
updated_at: profile?.updated_at ?? null,
|
||||
},
|
||||
roles: [role?.role ?? "—"],
|
||||
permissions,
|
||||
};
|
||||
},
|
||||
};
|
||||
75
services/usuariosApi.ts
Normal file
75
services/usuariosApi.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import api from "./api";
|
||||
|
||||
export interface UserRole {
|
||||
id: any;
|
||||
user_id: string;
|
||||
role: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: any;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FullUserData {
|
||||
user: User;
|
||||
profile: any;
|
||||
roles: string[];
|
||||
permissions: any;
|
||||
}
|
||||
|
||||
export const usuariosApi = {
|
||||
listRoles: async (): Promise<UserRole[]> => {
|
||||
const response = await api.get<UserRole[]>("/rest/v1/user_roles");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createUser: async (data: {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone?: string;
|
||||
phone_mobile?: string;
|
||||
role: string;
|
||||
cpf?: string;
|
||||
create_patient_record?: boolean;
|
||||
}): Promise<any> => {
|
||||
try {
|
||||
const response = await api.post("/functions/v1/create-user", data, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "",
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("supabase-access-token") ||
|
||||
""
|
||||
: ""
|
||||
}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ Erro no createUser:", error.response?.data || error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await api.post<User>(
|
||||
"/functions/v1/user-info",
|
||||
{},
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFullData: async (userId: string): Promise<FullUserData> => {
|
||||
const response = await api.post<FullUserData>(
|
||||
"/functions/v1/user-info-by-id",
|
||||
{ user_id: userId },
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -14,12 +18,26 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".", // adiciona esta linha
|
||||
"paths": { // adiciona esta linha
|
||||
"@/*": ["./*"] // e esta linha
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".", // adiciona esta linha
|
||||
"paths": { // adiciona esta linha
|
||||
"@/*": [
|
||||
"./*"
|
||||
] // e esta linha
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d",
|
||||
"**/*",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user