3545 lines
137 KiB
TypeScript
3545 lines
137 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
type ChangeEvent,
|
||
type FormEvent,
|
||
} from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import toast from "react-hot-toast";
|
||
import { useAuth } from "../hooks/useAuth";
|
||
import {
|
||
Activity,
|
||
Calendar,
|
||
Edit,
|
||
FileText,
|
||
Plus,
|
||
Search,
|
||
UserPlus,
|
||
Users,
|
||
X,
|
||
} from "lucide-react";
|
||
import PatientListTable, {
|
||
type PatientListItem,
|
||
} from "../components/pacientes/PatientListTable";
|
||
import PacienteForm from "../components/pacientes/PacienteForm";
|
||
import {
|
||
appointmentService,
|
||
type Appointment,
|
||
patientService,
|
||
type Patient,
|
||
type CreatePatientInput,
|
||
type UpdatePatientInput,
|
||
doctorService,
|
||
type Doctor,
|
||
type CreateDoctorInput,
|
||
type UpdateDoctorInput,
|
||
type CrmUF,
|
||
reportService,
|
||
type Report,
|
||
type CreateReportInput,
|
||
type UpdateReportInput,
|
||
} from "../services";
|
||
|
||
// Type aliases para compatibilidade com código legado
|
||
type Medico = Doctor;
|
||
type Relatorio = Report;
|
||
type PacienteServiceModel = Patient;
|
||
type EnderecoPaciente = {
|
||
rua: string;
|
||
numero: string;
|
||
complemento: string;
|
||
bairro: string;
|
||
cidade: string;
|
||
estado: string;
|
||
cep: string;
|
||
};
|
||
type MedicoUpdate = UpdateDoctorInput;
|
||
|
||
// Mock de ViaCEP
|
||
const buscarEnderecoViaCEP = async (cep: string) => {
|
||
try {
|
||
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
|
||
const data = await response.json();
|
||
if (data.erro) return null;
|
||
return {
|
||
rua: data.logradouro,
|
||
bairro: data.bairro,
|
||
cidade: data.localidade,
|
||
estado: data.uf,
|
||
cep: data.cep,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
import ScheduleAppointmentModal from "../components/agenda/ScheduleAppointmentModal";
|
||
import ConsultasSection from "../components/secretaria/ConsultasSection";
|
||
import RelatoriosSection from "../components/secretaria/RelatoriosSection";
|
||
import AgendaSection from "../components/secretaria/AgendaSection";
|
||
|
||
// Tipos e constantes reinseridos após refatoração
|
||
type TabId =
|
||
| "dashboard"
|
||
| "pacientes"
|
||
| "medicos"
|
||
| "consultas"
|
||
| "agenda"
|
||
| "relatorios";
|
||
|
||
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
|
||
|
||
const COUNTRY_OPTIONS = [
|
||
{ value: "55", label: "+55 🇧🇷 Brasil" },
|
||
{ value: "1", label: "+1 🇺🇸 EUA/Canadá" },
|
||
{ value: "93", label: "+93 🇦🇫 Afeganistão" },
|
||
{ value: "355", label: "+355 🇦🇱 Albânia" },
|
||
{ value: "49", label: "+49 🇩🇪 Alemanha" },
|
||
{ value: "376", label: "+376 🇦🇩 Andorra" },
|
||
{ value: "244", label: "+244 🇦🇴 Angola" },
|
||
{ value: "54", label: "+54 🇦🇷 Argentina" },
|
||
{ value: "374", label: "+374 🇦🇲 Armênia" },
|
||
{ value: "61", label: "+61 🇦🇺 Austrália" },
|
||
{ value: "43", label: "+43 🇦🇹 Áustria" },
|
||
{ value: "994", label: "+994 🇦🇿 Azerbaijão" },
|
||
{ value: "973", label: "+973 🇧🇭 Bahrein" },
|
||
{ value: "880", label: "+880 🇧🇩 Bangladesh" },
|
||
{ value: "375", label: "+375 🇧🇾 Bielorrússia" },
|
||
{ value: "32", label: "+32 🇧🇪 Bélgica" },
|
||
{ value: "501", label: "+501 🇧🇿 Belize" },
|
||
{ value: "591", label: "+591 🇧🇴 Bolívia" },
|
||
{ value: "387", label: "+387 🇧🇦 Bósnia" },
|
||
{ value: "267", label: "+267 🇧🇼 Botsuana" },
|
||
{ value: "359", label: "+359 🇧🇬 Bulgária" },
|
||
{ value: "226", label: "+226 🇧🇫 Burkina Faso" },
|
||
{ value: "257", label: "+257 🇧🇮 Burundi" },
|
||
{ value: "238", label: "+238 🇨🇻 Cabo Verde" },
|
||
{ value: "855", label: "+855 🇰🇭 Camboja" },
|
||
{ value: "237", label: "+237 🇨🇲 Camarões" },
|
||
{ value: "56", label: "+56 🇨🇱 Chile" },
|
||
{ value: "86", label: "+86 🇨🇳 China" },
|
||
{ value: "57", label: "+57 🇨🇴 Colômbia" },
|
||
{ value: "242", label: "+242 🇨🇬 Congo" },
|
||
{ value: "82", label: "+82 🇰🇷 Coreia do Sul" },
|
||
{ value: "850", label: "+850 🇰🇵 Coreia do Norte" },
|
||
{ value: "506", label: "+506 🇨🇷 Costa Rica" },
|
||
{ value: "225", label: "+225 🇨🇮 Costa do Marfim" },
|
||
{ value: "385", label: "+385 🇭🇷 Croácia" },
|
||
{ value: "53", label: "+53 🇨🇺 Cuba" },
|
||
{ value: "45", label: "+45 🇩🇰 Dinamarca" },
|
||
{ value: "593", label: "+593 🇪🇨 Equador" },
|
||
{ value: "20", label: "+20 🇪🇬 Egito" },
|
||
{ value: "503", label: "+503 🇸🇻 El Salvador" },
|
||
{ value: "971", label: "+971 🇦🇪 Emirados Árabes" },
|
||
{ value: "593", label: "+593 🇪🇨 Equador" },
|
||
{ value: "291", label: "+291 🇪🇷 Eritreia" },
|
||
{ value: "421", label: "+421 🇸🇰 Eslováquia" },
|
||
{ value: "386", label: "+386 🇸🇮 Eslovênia" },
|
||
{ value: "34", label: "+34 🇪🇸 Espanha" },
|
||
{ value: "251", label: "+251 🇪🇹 Etiópia" },
|
||
{ value: "679", label: "+679 🇫🇯 Fiji" },
|
||
{ value: "63", label: "+63 🇵🇭 Filipinas" },
|
||
{ value: "358", label: "+358 🇫🇮 Finlândia" },
|
||
{ value: "33", label: "+33 🇫🇷 França" },
|
||
{ value: "241", label: "+241 🇬🇦 Gabão" },
|
||
{ value: "220", label: "+220 🇬🇲 Gâmbia" },
|
||
{ value: "233", label: "+233 🇬🇭 Gana" },
|
||
{ value: "995", label: "+995 🇬🇪 Geórgia" },
|
||
{ value: "350", label: "+350 🇬🇮 Gibraltar" },
|
||
{ value: "30", label: "+30 🇬🇷 Grécia" },
|
||
{ value: "502", label: "+502 🇬🇹 Guatemala" },
|
||
{ value: "224", label: "+224 🇬🇳 Guiné" },
|
||
{ value: "245", label: "+245 🇬🇼 Guiné-Bissau" },
|
||
{ value: "509", label: "+509 🇭🇹 Haiti" },
|
||
{ value: "504", label: "+504 🇭🇳 Honduras" },
|
||
{ value: "852", label: "+852 🇭🇰 Hong Kong" },
|
||
{ value: "36", label: "+36 🇭🇺 Hungria" },
|
||
{ value: "967", label: "+967 🇾🇪 Iêmen" },
|
||
{ value: "91", label: "+91 🇮🇳 Índia" },
|
||
{ value: "62", label: "+62 🇮🇩 Indonésia" },
|
||
{ value: "98", label: "+98 🇮🇷 Irã" },
|
||
{ value: "964", label: "+964 🇮🇶 Iraque" },
|
||
{ value: "353", label: "+353 🇮🇪 Irlanda" },
|
||
{ value: "354", label: "+354 🇮🇸 Islândia" },
|
||
{ value: "972", label: "+972 🇮🇱 Israel" },
|
||
{ value: "39", label: "+39 🇮🇹 Itália" },
|
||
{ value: "81", label: "+81 🇯🇵 Japão" },
|
||
{ value: "962", label: "+962 🇯🇴 Jordânia" },
|
||
{ value: "254", label: "+254 🇰🇪 Quênia" },
|
||
{ value: "996", label: "+996 🇰🇬 Quirguistão" },
|
||
{ value: "383", label: "+383 🇽🇰 Kosovo" },
|
||
{ value: "965", label: "+965 🇰🇼 Kuwait" },
|
||
{ value: "856", label: "+856 🇱🇦 Laos" },
|
||
{ value: "371", label: "+371 🇱🇻 Letônia" },
|
||
{ value: "961", label: "+961 🇱🇧 Líbano" },
|
||
{ value: "266", label: "+266 🇱🇸 Lesoto" },
|
||
{ value: "231", label: "+231 🇱🇷 Libéria" },
|
||
{ value: "218", label: "+218 🇱🇾 Líbia" },
|
||
{ value: "423", label: "+423 🇱🇮 Liechtenstein" },
|
||
{ value: "370", label: "+370 🇱🇹 Lituânia" },
|
||
{ value: "352", label: "+352 🇱🇺 Luxemburgo" },
|
||
{ value: "853", label: "+853 🇲🇴 Macau" },
|
||
{ value: "389", label: "+389 🇲🇰 Macedônia" },
|
||
{ value: "261", label: "+261 🇲🇬 Madagascar" },
|
||
{ value: "60", label: "+60 🇲🇾 Malásia" },
|
||
{ value: "265", label: "+265 🇲🇼 Malawi" },
|
||
{ value: "960", label: "+960 🇲🇻 Maldivas" },
|
||
{ value: "223", label: "+223 🇲🇱 Mali" },
|
||
{ value: "356", label: "+356 🇲🇹 Malta" },
|
||
{ value: "212", label: "+212 🇲🇦 Marrocos" },
|
||
{ value: "230", label: "+230 🇲🇺 Maurício" },
|
||
{ value: "222", label: "+222 🇲🇷 Mauritânia" },
|
||
{ value: "52", label: "+52 🇲🇽 México" },
|
||
{ value: "95", label: "+95 🇲🇲 Mianmar" },
|
||
{ value: "258", label: "+258 🇲🇿 Moçambique" },
|
||
{ value: "373", label: "+373 🇲🇩 Moldávia" },
|
||
{ value: "377", label: "+377 🇲🇨 Mônaco" },
|
||
{ value: "976", label: "+976 🇲🇳 Mongólia" },
|
||
{ value: "382", label: "+382 🇲🇪 Montenegro" },
|
||
{ value: "264", label: "+264 🇳🇦 Namíbia" },
|
||
{ value: "977", label: "+977 🇳🇵 Nepal" },
|
||
{ value: "505", label: "+505 🇳🇮 Nicarágua" },
|
||
{ value: "227", label: "+227 🇳🇪 Níger" },
|
||
{ value: "234", label: "+234 🇳🇬 Nigéria" },
|
||
{ value: "47", label: "+47 🇳🇴 Noruega" },
|
||
{ value: "64", label: "+64 🇳🇿 Nova Zelândia" },
|
||
{ value: "968", label: "+968 🇴🇲 Omã" },
|
||
{ value: "31", label: "+31 🇳🇱 Países Baixos" },
|
||
{ value: "92", label: "+92 🇵🇰 Paquistão" },
|
||
{ value: "507", label: "+507 🇵🇦 Panamá" },
|
||
{ value: "675", label: "+675 🇵🇬 Papua Nova Guiné" },
|
||
{ value: "595", label: "+595 🇵🇾 Paraguai" },
|
||
{ value: "51", label: "+51 🇵🇪 Peru" },
|
||
{ value: "48", label: "+48 🇵🇱 Polônia" },
|
||
{ value: "351", label: "+351 🇵🇹 Portugal" },
|
||
{ value: "974", label: "+974 🇶🇦 Qatar" },
|
||
{ value: "44", label: "+44 🇬🇧 Reino Unido" },
|
||
{ value: "236", label: "+236 🇨🇫 Rep. Centro-Africana" },
|
||
{ value: "243", label: "+243 🇨🇩 Rep. Dem. do Congo" },
|
||
{ value: "420", label: "+420 🇨🇿 República Tcheca" },
|
||
{ value: "40", label: "+40 🇷🇴 Romênia" },
|
||
{ value: "250", label: "+250 🇷🇼 Ruanda" },
|
||
{ value: "7", label: "+7 🇷🇺 Rússia" },
|
||
{ value: "966", label: "+966 🇸🇦 Arábia Saudita" },
|
||
{ value: "221", label: "+221 🇸🇳 Senegal" },
|
||
{ value: "381", label: "+381 🇷🇸 Sérvia" },
|
||
{ value: "65", label: "+65 🇸🇬 Singapura" },
|
||
{ value: "963", label: "+963 🇸🇾 Síria" },
|
||
{ value: "252", label: "+252 🇸🇴 Somália" },
|
||
{ value: "94", label: "+94 🇱🇰 Sri Lanka" },
|
||
{ value: "268", label: "+268 🇸🇿 Suazilândia" },
|
||
{ value: "249", label: "+249 🇸🇩 Sudão" },
|
||
{ value: "211", label: "+211 🇸🇸 Sudão do Sul" },
|
||
{ value: "46", label: "+46 🇸🇪 Suécia" },
|
||
{ value: "41", label: "+41 🇨🇭 Suíça" },
|
||
{ value: "597", label: "+597 🇸🇷 Suriname" },
|
||
{ value: "66", label: "+66 🇹🇭 Tailândia" },
|
||
{ value: "886", label: "+886 🇹🇼 Taiwan" },
|
||
{ value: "992", label: "+992 🇹🇯 Tajiquistão" },
|
||
{ value: "255", label: "+255 🇹🇿 Tanzânia" },
|
||
{ value: "670", label: "+670 🇹🇱 Timor-Leste" },
|
||
{ value: "228", label: "+228 🇹🇬 Togo" },
|
||
{ value: "676", label: "+676 🇹🇴 Tonga" },
|
||
{ value: "216", label: "+216 🇹🇳 Tunísia" },
|
||
{ value: "993", label: "+993 🇹🇲 Turcomenistão" },
|
||
{ value: "90", label: "+90 🇹🇷 Turquia" },
|
||
{ value: "380", label: "+380 🇺🇦 Ucrânia" },
|
||
{ value: "256", label: "+256 🇺🇬 Uganda" },
|
||
{ value: "598", label: "+598 🇺🇾 Uruguai" },
|
||
{ value: "998", label: "+998 🇺🇿 Uzbequistão" },
|
||
{ value: "678", label: "+678 🇻🇺 Vanuatu" },
|
||
{ value: "58", label: "+58 🇻🇪 Venezuela" },
|
||
{ value: "84", label: "+84 🇻🇳 Vietnã" },
|
||
{ value: "260", label: "+260 🇿🇲 Zâmbia" },
|
||
{ value: "263", label: "+263 🇿🇼 Zimbábue" },
|
||
];
|
||
|
||
const CONVENIOS = [
|
||
"Particular",
|
||
"Unimed",
|
||
"SulAmérica",
|
||
"Bradesco Saúde",
|
||
"Amil",
|
||
"NotreDame",
|
||
];
|
||
|
||
const generateFallbackId = (): string => {
|
||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||
return crypto.randomUUID();
|
||
}
|
||
return Math.random().toString(36).slice(2, 11);
|
||
};
|
||
|
||
interface Consulta {
|
||
id: string;
|
||
pacienteId: string;
|
||
medicoId: string;
|
||
pacienteNome: string;
|
||
medicoNome: string;
|
||
dataHora: string; // ISO
|
||
tipo: string;
|
||
status:
|
||
| "agendada"
|
||
| "confirmada"
|
||
| "cancelada"
|
||
| "realizada"
|
||
| "faltou"
|
||
| string;
|
||
}
|
||
|
||
interface PacienteUI {
|
||
id: string;
|
||
nome: string;
|
||
email?: string;
|
||
telefone?: string;
|
||
codigoPais: string;
|
||
ddd: string;
|
||
numeroTelefone: string;
|
||
cpf?: string;
|
||
sexo?: string;
|
||
dataNascimento?: string;
|
||
tipo_sanguineo?: string;
|
||
altura?: number | null;
|
||
peso?: number | null;
|
||
convenio?: string | null;
|
||
numeroCarteirinha?: string | null;
|
||
observacoes?: string | null;
|
||
vip: boolean;
|
||
endereco: EnderecoPaciente;
|
||
}
|
||
|
||
interface PacienteForm {
|
||
id?: string;
|
||
nome: string;
|
||
social_name: string;
|
||
cpf: string;
|
||
sexo: string;
|
||
dataNascimento: string;
|
||
email: string;
|
||
codigoPais: string;
|
||
ddd: string;
|
||
numeroTelefone: string;
|
||
telefone?: string;
|
||
tipo_sanguineo: string;
|
||
altura: string;
|
||
peso: string;
|
||
convenio: string;
|
||
numeroCarteirinha: string;
|
||
observacoes: string;
|
||
endereco: EnderecoPaciente;
|
||
rg?: string;
|
||
estado_civil?: string;
|
||
profissao?: string;
|
||
telefoneSecundario?: string;
|
||
telefoneReferencia?: string;
|
||
codigo_legado?: string;
|
||
responsavel_nome?: string;
|
||
responsavel_cpf?: string;
|
||
documentos?: { tipo: string; numero: string }[];
|
||
}
|
||
|
||
interface MedicoForm {
|
||
id?: string;
|
||
nome: string;
|
||
email: string;
|
||
crm: string;
|
||
crmUf: string;
|
||
cpf: string;
|
||
telefone: string;
|
||
telefone2: string;
|
||
especialidade: string;
|
||
dataNascimento: string;
|
||
rg: string;
|
||
cep: string;
|
||
rua: string;
|
||
numero: string;
|
||
complemento: string;
|
||
bairro: string;
|
||
cidade: string;
|
||
estado: string;
|
||
senha: string;
|
||
}
|
||
|
||
const buildEmptyPacienteForm = (): PacienteForm => ({
|
||
nome: "",
|
||
social_name: "",
|
||
cpf: "",
|
||
sexo: "",
|
||
dataNascimento: "",
|
||
email: "",
|
||
codigoPais: "55",
|
||
ddd: "",
|
||
numeroTelefone: "",
|
||
telefone: undefined,
|
||
tipo_sanguineo: "",
|
||
altura: "",
|
||
peso: "",
|
||
convenio: "",
|
||
numeroCarteirinha: "",
|
||
observacoes: "",
|
||
endereco: {
|
||
rua: "",
|
||
numero: "",
|
||
complemento: "",
|
||
bairro: "",
|
||
cidade: "",
|
||
estado: "",
|
||
cep: "",
|
||
},
|
||
rg: "",
|
||
estado_civil: "",
|
||
profissao: "",
|
||
telefoneSecundario: "",
|
||
telefoneReferencia: "",
|
||
codigo_legado: "",
|
||
responsavel_nome: "",
|
||
responsavel_cpf: "",
|
||
documentos: [],
|
||
});
|
||
|
||
const buildEmptyMedicoForm = (): MedicoForm => ({
|
||
nome: "",
|
||
email: "",
|
||
crm: "",
|
||
crmUf: "SP",
|
||
cpf: "",
|
||
telefone: "",
|
||
telefone2: "",
|
||
especialidade: "",
|
||
dataNascimento: "",
|
||
rg: "",
|
||
cep: "",
|
||
rua: "",
|
||
numero: "",
|
||
complemento: "",
|
||
bairro: "",
|
||
cidade: "",
|
||
estado: "",
|
||
senha: "",
|
||
});
|
||
|
||
const maskCpf = (value: string) => {
|
||
const digits = value.replace(/\D/g, "").slice(0, 11);
|
||
let formatted = digits;
|
||
if (digits.length > 3) {
|
||
formatted = formatted.replace(/(\d{3})(\d)/, "$1.$2");
|
||
}
|
||
if (digits.length > 6) {
|
||
formatted = formatted.replace(/(\d{3})\.(\d{3})(\d)/, "$1.$2.$3");
|
||
}
|
||
if (digits.length > 9) {
|
||
formatted = formatted.replace(
|
||
/(\d{3})\.(\d{3})\.(\d{3})(\d{1,2})/,
|
||
"$1.$2.$3-$4"
|
||
);
|
||
}
|
||
return { formatted, digits };
|
||
};
|
||
|
||
const maskCep = (value: string) => {
|
||
const digits = value.replace(/\D/g, "").slice(0, 8);
|
||
if (digits.length > 5) {
|
||
return digits.replace(/(\d{5})(\d{1,3})/, "$1-$2");
|
||
}
|
||
return digits;
|
||
};
|
||
|
||
const splitTelefone = (telefone?: string) => {
|
||
if (!telefone) {
|
||
return { codigoPais: "55", ddd: "", numeroTelefone: "" };
|
||
}
|
||
const digits = telefone.replace(/\D/g, "");
|
||
if (digits.length <= 9) {
|
||
return { codigoPais: "55", ddd: "", numeroTelefone: digits };
|
||
}
|
||
const numeroTelefone = digits.slice(-9);
|
||
const ddd = digits.length >= 11 ? digits.slice(-11, -9) : "";
|
||
const codigoPais = digits.slice(0, digits.length - 9 - (ddd ? 2 : 0)) || "55";
|
||
return { codigoPais, ddd, numeroTelefone };
|
||
};
|
||
|
||
const composeTelefone = (codigoPais: string, ddd: string, numero: string) => {
|
||
const sanitizedCodigo = codigoPais.replace(/\D/g, "");
|
||
const sanitizedDDD = ddd.replace(/\D/g, "");
|
||
const sanitizedNumero = numero.replace(/\D/g, "");
|
||
if (!sanitizedCodigo && !sanitizedDDD && !sanitizedNumero) {
|
||
return undefined;
|
||
}
|
||
let formatted = sanitizedNumero;
|
||
if (sanitizedNumero.length > 5) {
|
||
formatted = sanitizedNumero.replace(/(\d{5})(\d{1,4})?/, "$1-$2");
|
||
}
|
||
if (sanitizedDDD) {
|
||
formatted = `(${sanitizedDDD}) ${formatted}`;
|
||
}
|
||
if (sanitizedCodigo) {
|
||
formatted = `+${sanitizedCodigo} ${formatted}`;
|
||
}
|
||
return formatted;
|
||
};
|
||
|
||
const normalizePaciente = (data: PacienteServiceModel): PacienteUI => {
|
||
const id = data.id || generateFallbackId();
|
||
const telefoneInfo = splitTelefone(data.phone_mobile);
|
||
return {
|
||
id,
|
||
nome: data.full_name ?? "",
|
||
email: data.email ?? "",
|
||
telefone: data.phone_mobile ?? undefined,
|
||
codigoPais: telefoneInfo.codigoPais || "55",
|
||
ddd: telefoneInfo.ddd,
|
||
numeroTelefone: telefoneInfo.numeroTelefone,
|
||
cpf: data.cpf ?? "",
|
||
sexo: data.sex ?? "",
|
||
dataNascimento: data.birth_date ?? "",
|
||
tipo_sanguineo: data.blood_type ?? "",
|
||
altura: data.height_m ?? null,
|
||
peso: data.weight_kg ?? null,
|
||
convenio: (data as any).convenio ?? null,
|
||
numeroCarteirinha: (data as any).numeroCarteirinha ?? null,
|
||
observacoes: (data as any).observacoes ?? null,
|
||
vip: Boolean((data as any).vip),
|
||
endereco: {
|
||
rua: data.street ?? "",
|
||
numero: data.number ?? "",
|
||
complemento: data.complement ?? "",
|
||
bairro: data.neighborhood ?? "",
|
||
cidade: data.city ?? "",
|
||
estado: data.state ?? "",
|
||
cep: data.cep ?? "",
|
||
},
|
||
};
|
||
};
|
||
|
||
const buildPacienteFormFromPaciente = (paciente: PacienteUI): PacienteForm => {
|
||
const { formatted } = maskCpf(paciente.cpf ?? "");
|
||
return {
|
||
id: paciente.id,
|
||
nome: paciente.nome,
|
||
social_name: "",
|
||
cpf: formatted,
|
||
sexo: paciente.sexo ?? "",
|
||
dataNascimento: paciente.dataNascimento ?? "",
|
||
email: paciente.email ?? "",
|
||
codigoPais: paciente.codigoPais || "55",
|
||
ddd: paciente.ddd,
|
||
numeroTelefone: paciente.numeroTelefone,
|
||
telefone: paciente.telefone,
|
||
tipo_sanguineo: paciente.tipo_sanguineo ?? "",
|
||
altura:
|
||
paciente.altura !== null && paciente.altura !== undefined
|
||
? String(paciente.altura)
|
||
: "",
|
||
peso:
|
||
paciente.peso !== null && paciente.peso !== undefined
|
||
? String(paciente.peso)
|
||
: "",
|
||
convenio: paciente.convenio ?? "",
|
||
numeroCarteirinha: paciente.numeroCarteirinha ?? "",
|
||
observacoes: paciente.observacoes ?? "",
|
||
endereco: {
|
||
rua: paciente.endereco.rua ?? "",
|
||
numero: paciente.endereco.numero ?? "",
|
||
complemento: paciente.endereco.complemento ?? "",
|
||
bairro: paciente.endereco.bairro ?? "",
|
||
cidade: paciente.endereco.cidade ?? "",
|
||
estado: paciente.endereco.estado ?? "",
|
||
cep: paciente.endereco.cep ?? "",
|
||
},
|
||
};
|
||
};
|
||
|
||
// Normalize doctor for components expecting legacy format
|
||
const normalizeMedico = (doctor: Doctor): any => ({
|
||
...doctor,
|
||
nome: doctor.full_name,
|
||
telefone: doctor.phone_mobile,
|
||
especialidade: doctor.specialty,
|
||
dataNascimento: doctor.birth_date,
|
||
});
|
||
|
||
// Normalize report for components expecting legacy format
|
||
const normalizeRelatorio = (report: Report): any => ({
|
||
...report,
|
||
exam: report.exam ?? undefined,
|
||
});
|
||
|
||
const formatTelefone = (paciente: PacienteUI) => {
|
||
const composed = composeTelefone(
|
||
paciente.codigoPais,
|
||
paciente.ddd,
|
||
paciente.numeroTelefone
|
||
);
|
||
return composed ?? paciente.telefone ?? "Telefone não informado";
|
||
};
|
||
|
||
const formatEmail = (email?: string) =>
|
||
email ? email.trim().toLowerCase() : "Não informado";
|
||
|
||
// formatarData e getStatusColor removidos após adoção de ConsultationList
|
||
|
||
// Formata ISO "YYYY-MM-DDTHH:mm:ss" sem alterar fuso/offset
|
||
const formatDateTimeLocal = (iso?: string) => {
|
||
if (!iso) return "";
|
||
const [date, timeRaw] = iso.split("T");
|
||
if (!date) return iso;
|
||
const [y, m, d] = date.split("-");
|
||
const time = (timeRaw || "").slice(0, 8); // HH:mm:ss
|
||
if (y && m && d && time) return `${d}/${m}/${y}, ${time}`;
|
||
if (y && m && d) return `${d}/${m}/${y}`;
|
||
return iso;
|
||
};
|
||
|
||
const buildMedicoTelefone = (value: string) => {
|
||
const digits = value.replace(/\D/g, "").slice(0, 13);
|
||
if (!digits) return "";
|
||
if (digits.length <= 2) return `(${digits}`;
|
||
if (digits.length <= 10) {
|
||
return digits.replace(
|
||
/(\d{2})(\d{4})(\d{0,4})?/,
|
||
(_, d1: string, d2: string, d3?: string) => {
|
||
if (d3) {
|
||
return `(${d1}) ${d2}-${d3}`;
|
||
}
|
||
return `(${d1}) ${d2}`;
|
||
}
|
||
);
|
||
}
|
||
return digits.replace(
|
||
/(\d{2})(\d{2})(\d{5})(\d{0,4})?/,
|
||
(_: string, pais: string, ddd: string, n1: string, n2?: string) => {
|
||
const base = `+${pais} (${ddd}) ${n1}`;
|
||
return n2 ? `${base}-${n2}` : base;
|
||
}
|
||
);
|
||
};
|
||
|
||
// Mapear status da API para status da UI
|
||
const mapAppointmentStatus = (status: string): Consulta["status"] => {
|
||
const statusMap: Record<string, Consulta["status"]> = {
|
||
requested: "agendada",
|
||
confirmed: "confirmada",
|
||
cancelled: "cancelada",
|
||
completed: "realizada",
|
||
no_show: "faltou",
|
||
checked_in: "confirmada",
|
||
in_progress: "confirmada",
|
||
};
|
||
return statusMap[status] || "agendada";
|
||
};
|
||
|
||
// Converter Appointment da API para Consulta da UI
|
||
const appointmentToConsulta = (
|
||
apt: Appointment,
|
||
pacientes: PacienteUI[],
|
||
medicos: Medico[]
|
||
): Consulta => {
|
||
const paciente = pacientes.find((p) => p.id === apt.patient_id);
|
||
const medico = medicos.find((m) => m.id === apt.doctor_id);
|
||
|
||
return {
|
||
id: apt.id || "",
|
||
pacienteId: apt.patient_id || "",
|
||
medicoId: apt.doctor_id || "",
|
||
pacienteNome: paciente?.nome || "Paciente não encontrado",
|
||
medicoNome: medico?.full_name || "Médico não encontrado",
|
||
dataHora: apt.scheduled_at || "",
|
||
tipo: apt.appointment_type || "consulta",
|
||
status: mapAppointmentStatus(apt.status || "requested"),
|
||
};
|
||
};
|
||
|
||
const PainelSecretaria = () => {
|
||
const navigate = useNavigate();
|
||
const { logout } = useAuth();
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<TabId>("pacientes");
|
||
const [relatorios, setRelatorios] = useState<Relatorio[]>([]);
|
||
const [loadingRelatorios, setLoadingRelatorios] = useState(false);
|
||
const [reportModalOpen, setReportModalOpen] = useState(false);
|
||
const [reportSaving, setReportSaving] = useState(false);
|
||
const [reportForm, setReportForm] = useState({
|
||
patientId: "",
|
||
orderNumber: "",
|
||
exam: "",
|
||
diagnosis: "",
|
||
conclusion: "",
|
||
dueAt: "", // YYYY-MM-DD
|
||
status: "draft" as "draft" | "pending" | "completed" | "cancelled",
|
||
});
|
||
const [reportModalMode, setReportModalMode] = useState<"create" | "edit">(
|
||
"create"
|
||
);
|
||
const [editingReportId, setEditingReportId] = useState<string | null>(null);
|
||
|
||
// Gera número padrão no formato exato solicitado: REL-YYYY-MM-XXXXXX (6 caracteres alfanuméricos)
|
||
const generateDefaultOrderNumber = () => {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||
let suffix = "";
|
||
for (let i = 0; i < 6; i++) {
|
||
suffix += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
return `REL-${year}-${month}-${suffix}`;
|
||
};
|
||
const ORDER_NUMBER_PATTERN = /^REL-\d{4}-\d{2}-[A-Z0-9]{6}$/;
|
||
const [reportDetailsOpen, setReportDetailsOpen] = useState(false);
|
||
const [reportDetailsLoading, setReportDetailsLoading] = useState(false);
|
||
const [reportDetails, setReportDetails] = useState<Relatorio | null>(null);
|
||
|
||
const openReportDetails = async (id?: string) => {
|
||
if (!id) return;
|
||
setReportDetailsLoading(true);
|
||
setReportDetails(null);
|
||
try {
|
||
const report = await reportService.getById(id);
|
||
setReportDetails(report);
|
||
setReportDetailsOpen(true);
|
||
} catch {
|
||
toast.error("Erro ao carregar relatório");
|
||
} finally {
|
||
setReportDetailsLoading(false);
|
||
}
|
||
};
|
||
const [selectedDoctorAgenda, setSelectedDoctorAgenda] = useState<string>("");
|
||
const [agendaDoctors, setAgendaDoctors] = useState<Medico[]>([]);
|
||
|
||
const [pacientes, setPacientes] = useState<PacienteUI[]>([]);
|
||
const [medicos, setMedicos] = useState<Medico[]>([]);
|
||
const [agendamentos, setAgendamentos] = useState<Appointment[]>([]);
|
||
const [agendamentosLoading, setAgendamentosLoading] = useState(false);
|
||
// Estados de filtros de agendamentos
|
||
const [consultaFiltroMedico, setConsultaFiltroMedico] = useState<string>("");
|
||
const [consultaFiltroPaciente, setConsultaFiltroPaciente] =
|
||
useState<string>("");
|
||
const [consultaFiltroStatus, setConsultaFiltroStatus] = useState<string>("");
|
||
const [consultaFiltroDataDe, setConsultaFiltroDataDe] = useState<string>("");
|
||
const [consultaFiltroDataAte, setConsultaFiltroDataAte] =
|
||
useState<string>("");
|
||
// Estado para lista de pacientes com dados de último/próximo atendimento
|
||
const [pacientesEnriquecidos, setPacientesEnriquecidos] = useState<
|
||
Record<string, { ultimo?: string | null; proximo?: string | null }>
|
||
>({});
|
||
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [searchId, setSearchId] = useState("");
|
||
const [selectedConvenio, setSelectedConvenio] = useState<string>("");
|
||
const [filterBirthday, setFilterBirthday] = useState(false);
|
||
const [filterVip, setFilterVip] = useState(false);
|
||
|
||
const [patientModalOpen, setPatientModalOpen] = useState(false);
|
||
const [patientModalMode, setPatientModalMode] = useState<"create" | "edit">(
|
||
"create"
|
||
);
|
||
const [formDataPaciente, setFormDataPaciente] = useState<PacienteForm>(
|
||
buildEmptyPacienteForm()
|
||
);
|
||
// Removida validação de CPF (local + externa)
|
||
|
||
const [doctorModalOpen, setDoctorModalOpen] = useState(false);
|
||
const [doctorModalMode, setDoctorModalMode] = useState<"create" | "edit">(
|
||
"create"
|
||
);
|
||
const [formDataMedico, setFormDataMedico] = useState<MedicoForm>(
|
||
buildEmptyMedicoForm()
|
||
);
|
||
|
||
// Estado para modal de agendamento
|
||
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
|
||
const [schedulePatientId, setSchedulePatientId] = useState("");
|
||
const [schedulePatientName, setSchedulePatientName] = useState("");
|
||
|
||
const carregarPacientes = useCallback(async () => {
|
||
try {
|
||
console.log("[PainelSecretaria] Carregando pacientes...");
|
||
const patients = await patientService.list();
|
||
console.log("[PainelSecretaria] Resposta pacientes:", patients);
|
||
console.log("[PainelSecretaria] Pacientes recebidos:", patients.length);
|
||
setPacientes(patients.map(normalizePaciente));
|
||
} catch (error) {
|
||
console.error("Erro ao carregar pacientes:", error);
|
||
toast.error("Erro ao carregar pacientes");
|
||
}
|
||
}, []);
|
||
|
||
const carregarMedicos = useCallback(async () => {
|
||
try {
|
||
console.log("[PainelSecretaria] Carregando médicos...");
|
||
const doctors = await doctorService.list();
|
||
console.log("[PainelSecretaria] Resposta médicos:", doctors);
|
||
if (doctors && doctors.length > 0) {
|
||
console.log("[PainelSecretaria] Médicos recebidos:", doctors.length);
|
||
setMedicos(doctors);
|
||
} else {
|
||
console.error("[PainelSecretaria] Erro na resposta");
|
||
toast.error("Erro ao carregar médicos");
|
||
}
|
||
} catch (error) {
|
||
console.error("Erro ao carregar médicos:", error);
|
||
toast.error("Erro ao carregar médicos");
|
||
}
|
||
}, []);
|
||
|
||
const carregarRelatorios = useCallback(async () => {
|
||
setLoadingRelatorios(true);
|
||
try {
|
||
const reports = await reportService.list();
|
||
setRelatorios(reports);
|
||
} catch (error) {
|
||
console.error("Erro ao carregar relatórios:", error);
|
||
toast.error("Erro ao carregar relatórios");
|
||
} finally {
|
||
setLoadingRelatorios(false);
|
||
}
|
||
}, []);
|
||
|
||
const carregarAgendamentos = useCallback(async () => {
|
||
setAgendamentosLoading(true);
|
||
try {
|
||
console.log("[PainelSecretaria] Carregando agendamentos...");
|
||
const response = await appointmentService.list();
|
||
console.log("[PainelSecretaria] Resposta agendamentos:", response);
|
||
if (response && response.length > 0) {
|
||
console.log(
|
||
"[PainelSecretaria] Agendamentos recebidos:",
|
||
response.length
|
||
);
|
||
setAgendamentos(response);
|
||
} else {
|
||
setAgendamentos([]);
|
||
}
|
||
} catch (error) {
|
||
console.error("Erro ao carregar agendamentos:", error);
|
||
toast.error("Erro ao carregar agendamentos");
|
||
} finally {
|
||
setAgendamentosLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const carregarDados = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
await Promise.all([
|
||
carregarPacientes(),
|
||
carregarMedicos(),
|
||
carregarRelatorios(),
|
||
carregarAgendamentos(),
|
||
]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [
|
||
carregarPacientes,
|
||
carregarMedicos,
|
||
carregarRelatorios,
|
||
carregarAgendamentos,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
void carregarDados();
|
||
}, [carregarDados]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === "relatorios") {
|
||
void carregarRelatorios();
|
||
}
|
||
}, [activeTab, carregarRelatorios]);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
const doctors = await doctorService.list();
|
||
// Filter active doctors if needed
|
||
const activeDoctors = doctors.filter((d: Doctor) => d.active !== false);
|
||
setAgendaDoctors(activeDoctors);
|
||
if (!selectedDoctorAgenda && activeDoctors.length) {
|
||
setSelectedDoctorAgenda(activeDoctors[0].id);
|
||
}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const resetPacienteForm = useCallback(() => {
|
||
setFormDataPaciente(buildEmptyPacienteForm());
|
||
}, []);
|
||
|
||
const resetMedicoForm = useCallback(() => {
|
||
setFormDataMedico(buildEmptyMedicoForm());
|
||
}, []);
|
||
|
||
const handleLogout = useCallback(() => {
|
||
console.log("[PainelSecretaria] Fazendo logout...");
|
||
logout();
|
||
toast.success("Sessão encerrada");
|
||
navigate("/login-secretaria");
|
||
}, [logout, navigate]);
|
||
|
||
const openCreatePacienteModal = useCallback(() => {
|
||
resetPacienteForm();
|
||
setPatientModalMode("create");
|
||
setPatientModalOpen(true);
|
||
}, [resetPacienteForm]);
|
||
|
||
const openEditPacienteModal = useCallback((paciente: PacienteUI) => {
|
||
setFormDataPaciente(buildPacienteFormFromPaciente(paciente));
|
||
setPatientModalMode("edit");
|
||
setPatientModalOpen(true);
|
||
}, []);
|
||
|
||
const closePacienteModal = useCallback(() => {
|
||
setPatientModalOpen(false);
|
||
}, []);
|
||
|
||
const openCreateMedicoModal = useCallback(() => {
|
||
resetMedicoForm();
|
||
setDoctorModalMode("create");
|
||
setDoctorModalOpen(true);
|
||
}, [resetMedicoForm]);
|
||
|
||
const openEditMedicoModal = useCallback((medico: Medico) => {
|
||
const medicoDetalhado = medico as Medico & Partial<Record<string, string>>;
|
||
setFormDataMedico({
|
||
id: medico.id,
|
||
nome: medico.full_name || "",
|
||
email: medico.email || "",
|
||
crm: medico.crm || "",
|
||
crmUf: medico.crm_uf || "",
|
||
cpf: medico.cpf || "",
|
||
telefone: medico.phone_mobile || "",
|
||
telefone2: medico.phone2 || "",
|
||
especialidade: medico.specialty || "",
|
||
dataNascimento: medico.birth_date || "",
|
||
rg: medico.rg || "",
|
||
cep: medicoDetalhado.cep || "",
|
||
rua: medicoDetalhado.rua || "",
|
||
numero: medicoDetalhado.numero || "",
|
||
complemento: medicoDetalhado.complemento || "",
|
||
bairro: medicoDetalhado.bairro || "",
|
||
cidade: medicoDetalhado.cidade || "",
|
||
estado: medicoDetalhado.estado || "",
|
||
senha: "",
|
||
});
|
||
setDoctorModalMode("edit");
|
||
setDoctorModalOpen(true);
|
||
}, []);
|
||
|
||
const closeMedicoModal = useCallback(() => {
|
||
setDoctorModalOpen(false);
|
||
}, []);
|
||
|
||
const handleBuscarPorId = useCallback(async () => {
|
||
if (!searchId) {
|
||
toast.error("Informe o ID do paciente");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const patient = await patientService.getById(searchId);
|
||
setPacientes([normalizePaciente(patient)]);
|
||
} catch (error) {
|
||
console.error("Erro ao buscar paciente:", error);
|
||
toast.error("Paciente não encontrado");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [searchId]);
|
||
|
||
const handleDeletePaciente = useCallback(async (paciente: PacienteUI) => {
|
||
if (!window.confirm(`Deseja remover o paciente ${paciente.nome}?`)) {
|
||
return;
|
||
}
|
||
try {
|
||
console.log("[PainelSecretaria] Deletando paciente:", {
|
||
id: paciente.id,
|
||
nome: paciente.nome,
|
||
});
|
||
|
||
await patientService.delete(paciente.id);
|
||
console.log("[PainelSecretaria] Paciente deletado com sucesso");
|
||
setPacientes((prev) => prev.filter((p) => p.id !== paciente.id));
|
||
toast.success("Paciente removido com sucesso");
|
||
} catch (error) {
|
||
console.error("[PainelSecretaria] Erro ao remover paciente:", error);
|
||
toast.error("Erro ao remover paciente");
|
||
}
|
||
}, []);
|
||
|
||
const handleDeleteMedico = useCallback(async (medico: Medico) => {
|
||
if (!window.confirm(`Deseja remover o médico ${medico.full_name}?`)) {
|
||
return;
|
||
}
|
||
try {
|
||
await doctorService.delete(medico.id!);
|
||
setMedicos((prev) => prev.filter((m) => m.id !== medico.id));
|
||
toast.success("Médico removido");
|
||
} catch (error) {
|
||
console.error("Erro ao remover médico:", error);
|
||
toast.error("Erro ao remover médico");
|
||
}
|
||
}, []);
|
||
|
||
const handleCpfChange = useCallback(
|
||
(event: ChangeEvent<HTMLInputElement>) => {
|
||
const { formatted } = maskCpf(event.target.value);
|
||
setFormDataPaciente((prev) => ({ ...prev, cpf: formatted }));
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleCepLookup = useCallback(async (rawCep: string) => {
|
||
const digits = rawCep.replace(/\D/g, "");
|
||
if (digits.length !== 8) return;
|
||
try {
|
||
const endereco = await buscarEnderecoViaCEP(digits);
|
||
if (!endereco) {
|
||
toast.error("CEP não encontrado");
|
||
return;
|
||
}
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
rua: endereco.rua ?? prev.endereco.rua,
|
||
bairro: endereco.bairro ?? prev.endereco.bairro,
|
||
cidade: endereco.cidade ?? prev.endereco.cidade,
|
||
estado: endereco.estado ?? prev.endereco.estado,
|
||
cep: endereco.cep ?? rawCep,
|
||
},
|
||
}));
|
||
} catch (error) {
|
||
console.warn("Erro ao buscar CEP:", error);
|
||
toast.error("Erro ao buscar CEP");
|
||
}
|
||
}, []);
|
||
|
||
const handleCepLookupMedico = useCallback(async (rawCep: string) => {
|
||
const digits = rawCep.replace(/\D/g, "");
|
||
if (digits.length !== 8) return;
|
||
try {
|
||
const endereco = await buscarEnderecoViaCEP(digits);
|
||
if (!endereco) {
|
||
toast.error("CEP não encontrado");
|
||
return;
|
||
}
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
rua: endereco.rua ?? prev.rua,
|
||
bairro: endereco.bairro ?? prev.bairro,
|
||
cidade: endereco.cidade ?? prev.cidade,
|
||
estado: endereco.estado ?? prev.estado,
|
||
cep: endereco.cep ?? rawCep,
|
||
}));
|
||
toast.success("Endereço preenchido automaticamente!");
|
||
} catch (error) {
|
||
console.warn("Erro ao buscar CEP:", error);
|
||
toast.error("Erro ao buscar CEP");
|
||
}
|
||
}, []);
|
||
|
||
const handleSubmitPaciente = useCallback(
|
||
async (event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const { digits } = maskCpf(formDataPaciente.cpf);
|
||
|
||
// Validação de CPF removida – apenas mascaramento e envio dos dígitos.
|
||
|
||
setLoading(true);
|
||
try {
|
||
// Validação externa de CPF removida
|
||
|
||
const telefone =
|
||
composeTelefone(
|
||
formDataPaciente.codigoPais,
|
||
formDataPaciente.ddd,
|
||
formDataPaciente.numeroTelefone
|
||
) ?? formDataPaciente.telefone;
|
||
|
||
const payload: Partial<CreatePatientInput> = {
|
||
full_name: formDataPaciente.nome,
|
||
cpf: digits,
|
||
email: formDataPaciente.email,
|
||
phone_mobile: telefone || "",
|
||
sex: formDataPaciente.sexo,
|
||
birth_date: formDataPaciente.dataNascimento,
|
||
blood_type: formDataPaciente.tipo_sanguineo,
|
||
street: formDataPaciente.endereco.rua,
|
||
number: formDataPaciente.endereco.numero,
|
||
complement: formDataPaciente.endereco.complemento,
|
||
neighborhood: formDataPaciente.endereco.bairro,
|
||
city: formDataPaciente.endereco.cidade,
|
||
state: formDataPaciente.endereco.estado,
|
||
cep: formDataPaciente.endereco.cep,
|
||
social_name: formDataPaciente.social_name || undefined,
|
||
};
|
||
|
||
// Campos estendidos ainda não suportados pelo backend oficial (armazenar localmente para futura sincronização)
|
||
interface ExtendedPacienteMeta {
|
||
rg?: string;
|
||
estado_civil?: string;
|
||
profissao?: string;
|
||
telefoneSecundario?: string;
|
||
telefoneReferencia?: string;
|
||
codigo_legado?: string;
|
||
responsavel_nome?: string;
|
||
responsavel_cpf?: string;
|
||
documentos?: { tipo: string; numero: string }[];
|
||
updatedAt?: string;
|
||
}
|
||
const extended: ExtendedPacienteMeta = {
|
||
rg: formDataPaciente.rg,
|
||
estado_civil: formDataPaciente.estado_civil,
|
||
profissao: formDataPaciente.profissao,
|
||
telefoneSecundario: formDataPaciente.telefoneSecundario,
|
||
telefoneReferencia: formDataPaciente.telefoneReferencia,
|
||
codigo_legado: formDataPaciente.codigo_legado,
|
||
responsavel_nome: formDataPaciente.responsavel_nome,
|
||
responsavel_cpf: formDataPaciente.responsavel_cpf,
|
||
documentos: formDataPaciente.documentos || [],
|
||
};
|
||
|
||
// Persistir metadados localmente (namespace pacientes_meta) para fins de prontuário até backend
|
||
try {
|
||
const metaRaw = localStorage.getItem("pacientes_meta") || "{}";
|
||
const meta = JSON.parse(metaRaw) as Record<
|
||
string,
|
||
ExtendedPacienteMeta
|
||
>;
|
||
meta[formDataPaciente.id || digits] = {
|
||
...(meta[formDataPaciente.id || digits] || {}),
|
||
...extended,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
localStorage.setItem("pacientes_meta", JSON.stringify(meta));
|
||
} catch {
|
||
// falha silenciosa
|
||
}
|
||
|
||
if (formDataPaciente.altura.trim()) {
|
||
payload.height_m = Number(formDataPaciente.altura);
|
||
}
|
||
if (formDataPaciente.peso.trim()) {
|
||
payload.weight_kg = Number(formDataPaciente.peso);
|
||
}
|
||
|
||
if (patientModalMode === "create") {
|
||
const newPatient = await patientService.create(
|
||
payload as CreatePatientInput
|
||
);
|
||
setPacientes((prev) => [...prev, normalizePaciente(newPatient)]);
|
||
toast.success("Paciente cadastrado com sucesso!");
|
||
} else if (patientModalMode === "edit" && formDataPaciente.id) {
|
||
const updatedPatient = await patientService.update(
|
||
formDataPaciente.id,
|
||
payload as UpdatePatientInput
|
||
);
|
||
const normalizado = normalizePaciente(updatedPatient);
|
||
setPacientes((prev) =>
|
||
prev.map((p) => (p.id === formDataPaciente.id ? normalizado : p))
|
||
);
|
||
toast.success("Paciente atualizado com sucesso!");
|
||
}
|
||
|
||
resetPacienteForm();
|
||
setPatientModalOpen(false);
|
||
} catch (error) {
|
||
console.error("Erro ao salvar paciente:", error);
|
||
toast.error("Erro ao salvar paciente");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[formDataPaciente, patientModalMode, resetPacienteForm]
|
||
);
|
||
|
||
const handleSubmitMedico = useCallback(
|
||
async (event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
setLoading(true);
|
||
try {
|
||
if (doctorModalMode === "create") {
|
||
const payload = {
|
||
full_name: formDataMedico.nome,
|
||
email: formDataMedico.email,
|
||
crm: formDataMedico.crm,
|
||
crm_uf: formDataMedico.crmUf as CrmUF,
|
||
cpf: formDataMedico.cpf,
|
||
phone_mobile: formDataMedico.telefone,
|
||
phone2: formDataMedico.telefone2,
|
||
specialty: formDataMedico.especialidade,
|
||
birth_date: formDataMedico.dataNascimento,
|
||
rg: formDataMedico.rg,
|
||
cep: formDataMedico.cep,
|
||
street: formDataMedico.rua,
|
||
number: formDataMedico.numero,
|
||
complement: formDataMedico.complemento,
|
||
neighborhood: formDataMedico.bairro,
|
||
city: formDataMedico.cidade,
|
||
state: formDataMedico.estado,
|
||
};
|
||
const newDoctor = await doctorService.create(payload);
|
||
setMedicos((prev) => [...prev, newDoctor]);
|
||
toast.success("Médico cadastrado com sucesso!");
|
||
} else if (doctorModalMode === "edit" && formDataMedico.id) {
|
||
const payload: MedicoUpdate = {
|
||
full_name: formDataMedico.nome,
|
||
email: formDataMedico.email,
|
||
crm: formDataMedico.crm,
|
||
crm_uf: formDataMedico.crmUf as CrmUF,
|
||
cpf: formDataMedico.cpf,
|
||
phone_mobile: formDataMedico.telefone,
|
||
phone2: formDataMedico.telefone2,
|
||
specialty: formDataMedico.especialidade,
|
||
birth_date: formDataMedico.dataNascimento,
|
||
rg: formDataMedico.rg,
|
||
cep: formDataMedico.cep,
|
||
street: formDataMedico.rua,
|
||
number: formDataMedico.numero,
|
||
complement: formDataMedico.complemento,
|
||
neighborhood: formDataMedico.bairro,
|
||
city: formDataMedico.cidade,
|
||
state: formDataMedico.estado,
|
||
};
|
||
const updatedDoctor = await doctorService.update(
|
||
formDataMedico.id,
|
||
payload
|
||
);
|
||
setMedicos((prev) =>
|
||
prev.map((m) => (m.id === formDataMedico.id ? updatedDoctor : m))
|
||
);
|
||
toast.success("Médico atualizado com sucesso!");
|
||
}
|
||
|
||
resetMedicoForm();
|
||
setDoctorModalOpen(false);
|
||
} catch (error) {
|
||
console.error("Erro ao salvar médico:", error);
|
||
toast.error("Erro ao salvar médico");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[doctorModalMode, formDataMedico, resetMedicoForm]
|
||
);
|
||
|
||
const conveniosDisponiveis = useMemo(() => {
|
||
const values = new Set<string>();
|
||
CONVENIOS.forEach((item) => values.add(item));
|
||
pacientes.forEach((paciente) => {
|
||
const convenio = paciente.convenio?.trim();
|
||
if (convenio) {
|
||
values.add(convenio);
|
||
}
|
||
});
|
||
return Array.from(values).sort((a, b) =>
|
||
a.localeCompare(b, "pt-BR", { sensitivity: "base" })
|
||
);
|
||
}, [pacientes]);
|
||
|
||
const pacientesFiltrados = useMemo(() => {
|
||
const termo = searchTerm.trim().toLowerCase();
|
||
const convenioFiltro = selectedConvenio.trim().toLowerCase();
|
||
const currentMonth = new Date().getMonth();
|
||
|
||
return pacientes.filter((paciente) => {
|
||
const nome = paciente.nome?.toLowerCase() ?? "";
|
||
const email = (paciente.email ?? "").toLowerCase();
|
||
const matchesSearch =
|
||
!termo || nome.includes(termo) || email.includes(termo);
|
||
if (!matchesSearch) return false;
|
||
|
||
const matchesConvenio =
|
||
!convenioFiltro ||
|
||
(paciente.convenio ?? "").trim().toLowerCase() === convenioFiltro;
|
||
if (!matchesConvenio) return false;
|
||
|
||
if (filterVip && !paciente.vip) return false;
|
||
|
||
if (filterBirthday) {
|
||
if (!paciente.dataNascimento) return false;
|
||
const data = new Date(paciente.dataNascimento);
|
||
if (Number.isNaN(data.getTime())) return false;
|
||
if (data.getMonth() !== currentMonth) return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}, [pacientes, searchTerm, selectedConvenio, filterVip, filterBirthday]);
|
||
|
||
// Converter agendamentos da API para o formato de consultas da UI
|
||
const consultas = useMemo(() => {
|
||
return agendamentos.map((apt) =>
|
||
appointmentToConsulta(apt, pacientes, medicos)
|
||
);
|
||
}, [agendamentos, pacientes, medicos]);
|
||
|
||
// Enriquecer pacientes com info de consultas
|
||
useEffect(() => {
|
||
const baseIds = new Set(pacientesFiltrados.map((p) => p.id));
|
||
const enriq: Record<
|
||
string,
|
||
{ ultimo?: string | null; proximo?: string | null }
|
||
> = {};
|
||
|
||
baseIds.forEach((id) => {
|
||
const consultasPaciente = consultas.filter((c) => c.pacienteId === id);
|
||
if (consultasPaciente.length === 0) {
|
||
enriq[id] = { ultimo: null, proximo: null };
|
||
return;
|
||
}
|
||
|
||
const agora = new Date();
|
||
const passadas = consultasPaciente
|
||
.filter((c) => new Date(c.dataHora) < agora)
|
||
.sort(
|
||
(a, b) =>
|
||
new Date(b.dataHora).getTime() - new Date(a.dataHora).getTime()
|
||
);
|
||
const futuras = consultasPaciente
|
||
.filter((c) => new Date(c.dataHora) >= agora)
|
||
.sort(
|
||
(a, b) =>
|
||
new Date(a.dataHora).getTime() - new Date(b.dataHora).getTime()
|
||
);
|
||
|
||
enriq[id] = {
|
||
ultimo: passadas[0]
|
||
? new Date(passadas[0].dataHora).toLocaleDateString("pt-BR", {
|
||
dateStyle: "short",
|
||
})
|
||
: null,
|
||
proximo: futuras[0]
|
||
? new Date(futuras[0].dataHora).toLocaleDateString("pt-BR", {
|
||
dateStyle: "short",
|
||
})
|
||
: null,
|
||
};
|
||
});
|
||
|
||
setPacientesEnriquecidos(enriq);
|
||
}, [pacientesFiltrados, consultas]);
|
||
|
||
const medicosFiltrados = useMemo(() => {
|
||
const termo = searchTerm.toLowerCase();
|
||
return medicos.filter(
|
||
(medico) =>
|
||
(medico.full_name || "").toLowerCase().includes(termo) ||
|
||
(medico.specialty ?? "").toLowerCase().includes(termo)
|
||
);
|
||
}, [medicos, searchTerm]);
|
||
|
||
const consultasFiltradas = useMemo(() => {
|
||
return consultas.filter((c) => {
|
||
if (searchTerm) {
|
||
const termo = searchTerm.toLowerCase();
|
||
if (
|
||
!(
|
||
c.pacienteNome.toLowerCase().includes(termo) ||
|
||
c.medicoNome.toLowerCase().includes(termo) ||
|
||
c.tipo.toLowerCase().includes(termo)
|
||
)
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
if (
|
||
consultaFiltroMedico &&
|
||
c.medicoNome !== consultaFiltroMedico &&
|
||
c.medicoNome !== consultaFiltroMedico
|
||
) {
|
||
// ajuste: filtro usa id, adaptar quando mapeamento existir
|
||
}
|
||
if (consultaFiltroStatus && c.status !== consultaFiltroStatus)
|
||
return false;
|
||
if (consultaFiltroDataDe) {
|
||
if (new Date(c.dataHora) < new Date(consultaFiltroDataDe)) return false;
|
||
}
|
||
if (consultaFiltroDataAte) {
|
||
if (
|
||
new Date(c.dataHora) > new Date(consultaFiltroDataAte + "T23:59:59")
|
||
)
|
||
return false;
|
||
}
|
||
if (
|
||
consultaFiltroPaciente &&
|
||
c.pacienteNome !== consultaFiltroPaciente &&
|
||
c.pacienteNome !== consultaFiltroPaciente
|
||
) {
|
||
// idem futuro mapeamento por id
|
||
}
|
||
return true;
|
||
});
|
||
}, [
|
||
consultas,
|
||
searchTerm,
|
||
consultaFiltroMedico,
|
||
consultaFiltroPaciente,
|
||
consultaFiltroStatus,
|
||
consultaFiltroDataDe,
|
||
consultaFiltroDataAte,
|
||
]);
|
||
|
||
// Funções para manipular agendamentos via API
|
||
const alterarStatusConsulta = async (id: string, novoStatus: string) => {
|
||
try {
|
||
// Mapear status da UI para status da API
|
||
const statusMap: Record<
|
||
string,
|
||
| "requested"
|
||
| "confirmed"
|
||
| "cancelled"
|
||
| "completed"
|
||
| "no_show"
|
||
| "checked_in"
|
||
| "in_progress"
|
||
> = {
|
||
agendada: "requested",
|
||
confirmada: "confirmed",
|
||
cancelada: "cancelled",
|
||
realizada: "completed",
|
||
faltou: "no_show",
|
||
};
|
||
const apiStatus = statusMap[novoStatus] || "requested";
|
||
|
||
await appointmentService.update(id, {
|
||
status: apiStatus,
|
||
});
|
||
await carregarAgendamentos();
|
||
toast.success("Status atualizado");
|
||
} catch {
|
||
toast.error("Erro ao atualizar status");
|
||
}
|
||
};
|
||
|
||
const deletarConsulta = async (id: string) => {
|
||
const confirma = window.confirm("Confirma excluir este agendamento?");
|
||
if (!confirma) return;
|
||
try {
|
||
await appointmentService.delete(id);
|
||
await carregarAgendamentos();
|
||
toast.success("Agendamento excluído");
|
||
} catch {
|
||
toast.error("Erro ao excluir");
|
||
}
|
||
};
|
||
|
||
// Agendamentos são carregados via API no carregarDados()
|
||
|
||
const isInitialLoading =
|
||
loading &&
|
||
!patientModalOpen &&
|
||
!doctorModalOpen &&
|
||
pacientes.length === 0 &&
|
||
medicos.length === 0;
|
||
|
||
if (isInitialLoading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto mb-4" />
|
||
<p className="text-gray-600">Carregando painel da secretária...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
{/* Header */}
|
||
<header className="bg-card border-b border-border sticky top-0 z-50">
|
||
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="flex items-center justify-between h-16">
|
||
{/* Logo */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
|
||
<Activity className="w-6 h-6 text-primary-foreground" />
|
||
</div>
|
||
<span className="text-xl font-bold text-foreground hidden sm:inline">
|
||
Secretária
|
||
</span>
|
||
</div>
|
||
|
||
{/* Navigation */}
|
||
<nav className="flex items-center gap-1">
|
||
{[
|
||
{
|
||
id: "dashboard" as TabId,
|
||
label: "Dashboard",
|
||
icon: Activity,
|
||
},
|
||
{ id: "pacientes" as TabId, label: "Pacientes", icon: Users },
|
||
{ id: "medicos" as TabId, label: "Médicos", icon: UserPlus },
|
||
{
|
||
id: "consultas" as TabId,
|
||
label: "Consultas",
|
||
icon: Calendar,
|
||
},
|
||
{ id: "agenda" as TabId, label: "Agenda", icon: Calendar },
|
||
{
|
||
id: "relatorios" as TabId,
|
||
label: "Relatórios",
|
||
icon: FileText,
|
||
},
|
||
].map((tab) => {
|
||
const Icon = tab.icon;
|
||
const selected = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||
selected
|
||
? "bg-primary/10 text-primary"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
<span className="hidden md:inline">{tab.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={openCreatePacienteModal}
|
||
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-md text-sm transition-all"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span className="hidden sm:inline">Novo</span>
|
||
</button>
|
||
<button
|
||
onClick={openCreateMedicoModal}
|
||
className="inline-flex items-center gap-2 bg-gray-800 hover:bg-gray-900 text-white px-3 py-2 rounded-md text-sm transition-all"
|
||
>
|
||
<UserPlus className="w-4 h-4" />
|
||
<span className="hidden sm:inline">Médico</span>
|
||
</button>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="inline-flex items-center gap-2 bg-destructive hover:bg-destructive/90 text-destructive-foreground px-3 py-2 rounded-md text-sm transition-all"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
<span className="hidden sm:inline">Sair</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||
{activeTab === "dashboard" && (
|
||
<section className="space-y-6">
|
||
{/* Stats Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
<div className="bg-card rounded-lg border border-border p-6 hover:shadow-lg transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-muted-foreground">
|
||
Pacientes
|
||
</p>
|
||
<p className="text-3xl font-bold text-foreground mt-2">
|
||
{pacientes.length}
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||
<Users className="w-6 h-6 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-card rounded-lg border border-border p-6 hover:shadow-lg transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-muted-foreground">
|
||
Médicos
|
||
</p>
|
||
<p className="text-3xl font-bold text-foreground mt-2">
|
||
{medicos.length}
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||
<UserPlus className="w-6 h-6 text-green-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-card rounded-lg border border-border p-6 hover:shadow-lg transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-muted-foreground">
|
||
Consultas
|
||
</p>
|
||
<p className="text-3xl font-bold text-foreground mt-2">
|
||
{consultas.length}
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
||
<Calendar className="w-6 h-6 text-purple-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-card rounded-lg border border-border p-6 hover:shadow-lg transition-shadow">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-muted-foreground">
|
||
Relatórios
|
||
</p>
|
||
<p className="text-3xl font-bold text-foreground mt-2">
|
||
{relatorios.length}
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||
<FileText className="w-6 h-6 text-orange-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Actions */}
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||
Ações Rápidas
|
||
</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<button
|
||
onClick={openCreatePacienteModal}
|
||
className="bg-card rounded-lg border border-border p-6 hover:bg-accent hover:border-primary transition-all text-left group"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center group-hover:bg-green-500/20 transition-colors">
|
||
<Plus className="w-6 h-6 text-green-600" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-foreground">
|
||
Novo Paciente
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
Cadastrar paciente
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={openCreateMedicoModal}
|
||
className="bg-card rounded-lg border border-border p-6 hover:bg-accent hover:border-primary transition-all text-left group"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-lg bg-blue-500/10 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||
<UserPlus className="w-6 h-6 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-foreground">
|
||
Novo Médico
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
Cadastrar médico
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => {
|
||
setSchedulePatientId("");
|
||
setSchedulePatientName("");
|
||
setScheduleModalOpen(true);
|
||
}}
|
||
className="bg-card rounded-lg border border-border p-6 hover:bg-accent hover:border-primary transition-all text-left group"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-lg bg-purple-500/10 flex items-center justify-center group-hover:bg-purple-500/20 transition-colors">
|
||
<Calendar className="w-6 h-6 text-purple-600" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-foreground">
|
||
Nova Consulta
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
Agendar consulta
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Activity Feed */}
|
||
<div className="bg-card rounded-lg border border-border p-6">
|
||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||
Atividade Recente
|
||
</h2>
|
||
<div className="space-y-4">
|
||
{consultas.slice(0, 5).map((consulta) => (
|
||
<div
|
||
key={consulta.id}
|
||
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent transition-colors"
|
||
>
|
||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||
<Calendar className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-foreground">
|
||
Consulta: {consulta.pacienteNome}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
com {consulta.medicoNome}
|
||
</p>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">
|
||
{consulta.dataHora
|
||
? new Date(consulta.dataHora).toLocaleDateString(
|
||
"pt-BR"
|
||
)
|
||
: "-"}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{consultas.length === 0 && (
|
||
<p className="text-sm text-muted-foreground text-center py-8">
|
||
Nenhuma atividade recente
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === "pacientes" && (
|
||
<section className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-foreground">
|
||
Pacientes
|
||
</h1>
|
||
<p className="text-muted-foreground">
|
||
Gerencie todos os pacientes cadastrados
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search and Filters */}
|
||
<div className="bg-card rounded-lg border border-border p-4 space-y-4">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||
<div className="flex-1 relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar pacientes por nome ou email..."
|
||
value={searchTerm}
|
||
onChange={(event) => setSearchTerm(event.target.value)}
|
||
className="pl-10 w-full h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar por ID"
|
||
value={searchId}
|
||
onChange={(event) => setSearchId(event.target.value)}
|
||
className="w-48 h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||
/>
|
||
<button
|
||
onClick={handleBuscarPorId}
|
||
className="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md transition-colors"
|
||
>
|
||
Buscar
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm("");
|
||
setSearchId("");
|
||
setSelectedConvenio("");
|
||
setFilterBirthday(false);
|
||
setFilterVip(false);
|
||
void carregarPacientes();
|
||
}}
|
||
className="inline-flex items-center gap-2 border border-input hover:bg-accent text-foreground px-4 py-2 rounded-md transition-colors"
|
||
>
|
||
Limpar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="flex flex-wrap gap-3">
|
||
<label className="inline-flex items-center gap-2 text-sm text-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={filterBirthday}
|
||
onChange={(event) =>
|
||
setFilterBirthday(event.target.checked)
|
||
}
|
||
className="rounded border-input text-primary focus:ring-primary"
|
||
/>
|
||
Aniversariantes do mês
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm text-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={filterVip}
|
||
onChange={(event) => setFilterVip(event.target.checked)}
|
||
className="rounded border-input text-primary focus:ring-primary"
|
||
/>
|
||
Somente VIP
|
||
</label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
Convênio
|
||
</label>
|
||
<select
|
||
value={selectedConvenio}
|
||
onChange={(event) =>
|
||
setSelectedConvenio(event.target.value)
|
||
}
|
||
className="h-9 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||
>
|
||
<option value="">Todos</option>
|
||
{conveniosDisponiveis.map((item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Patient Table */}
|
||
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||
<PatientListTable
|
||
pacientes={pacientesFiltrados.map<PatientListItem>((p) => ({
|
||
id: p.id,
|
||
nome: p.nome,
|
||
cpf: p.cpf,
|
||
email: formatEmail(p.email),
|
||
telefoneFormatado: formatTelefone(p),
|
||
convenio: p.convenio,
|
||
vip: p.vip,
|
||
cidade: p.endereco?.cidade,
|
||
estado: p.endereco?.estado,
|
||
ultimoAtendimento:
|
||
pacientesEnriquecidos[p.id]?.ultimo ?? null,
|
||
proximoAtendimento:
|
||
pacientesEnriquecidos[p.id]?.proximo ?? null,
|
||
}))}
|
||
onEdit={(item) => {
|
||
const original = pacientesFiltrados.find(
|
||
(pf) => pf.id === item.id
|
||
);
|
||
if (original) openEditPacienteModal(original);
|
||
}}
|
||
onDelete={(item) => {
|
||
const original = pacientesFiltrados.find(
|
||
(pf) => pf.id === item.id
|
||
);
|
||
if (original) void handleDeletePaciente(original);
|
||
}}
|
||
onView={(item) => {
|
||
navigate(`/pacientes/${encodeURIComponent(item.id)}`);
|
||
}}
|
||
onSchedule={(item) => {
|
||
setSchedulePatientId(item.id);
|
||
setSchedulePatientName(item.nome);
|
||
setScheduleModalOpen(true);
|
||
}}
|
||
/>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === "medicos" && (
|
||
<section className="space-y-6">
|
||
{/* Header */}
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-foreground">Médicos</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Gerencie os médicos cadastrados na clínica
|
||
</p>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="bg-card border border-border rounded-lg p-4">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar médicos por nome ou especialidade..."
|
||
value={searchTerm}
|
||
onChange={(event) => setSearchTerm(event.target.value)}
|
||
className="pl-10 pr-4 py-2 w-full bg-background border border-input rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-border">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
Médico
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
Especialidade
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
Contato
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
Ações
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-card divide-y divide-border">
|
||
{medicosFiltrados.map((medico) => {
|
||
const getInitials = (nome: string) => {
|
||
return nome
|
||
.split(" ")
|
||
.map((n) => n[0])
|
||
.join("")
|
||
.substring(0, 2)
|
||
.toUpperCase();
|
||
};
|
||
|
||
const getColorClass = (nome: string) => {
|
||
const colors = [
|
||
"bg-blue-500",
|
||
"bg-green-500",
|
||
"bg-purple-500",
|
||
"bg-pink-500",
|
||
"bg-yellow-500",
|
||
"bg-indigo-500",
|
||
];
|
||
const index =
|
||
nome
|
||
.split("")
|
||
.reduce(
|
||
(acc, char) => acc + char.charCodeAt(0),
|
||
0
|
||
) % colors.length;
|
||
return colors[index];
|
||
};
|
||
|
||
return (
|
||
<tr
|
||
key={medico.id}
|
||
className="hover:bg-muted/30 transition-colors"
|
||
>
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm ${getColorClass(
|
||
medico.full_name || "A"
|
||
)}`}
|
||
>
|
||
{getInitials(medico.full_name || "Sem nome")}
|
||
</div>
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
Dr(a). {medico.full_name || "Sem nome"}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
CRM: {medico.crm || "Não informado"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-muted-foreground">
|
||
{medico.specialty || "Não informado"}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="text-sm text-foreground">
|
||
{formatEmail(medico.email)}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
{medico.phone_mobile || "Telefone não informado"}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-right text-sm font-medium">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={() => openEditMedicoModal(medico)}
|
||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
Editar
|
||
</button>
|
||
<button
|
||
onClick={() => void handleDeleteMedico(medico)}
|
||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
Excluir
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{medicosFiltrados.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={4}
|
||
className="px-6 py-10 text-center text-sm text-muted-foreground"
|
||
>
|
||
Nenhum médico encontrado.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === "consultas" && (
|
||
<ConsultasSection
|
||
consultas={consultasFiltradas}
|
||
loading={agendamentosLoading}
|
||
onRefresh={carregarAgendamentos}
|
||
onNovaConsulta={() => {
|
||
setSchedulePatientId("");
|
||
setSchedulePatientName("");
|
||
setScheduleModalOpen(true);
|
||
}}
|
||
onDeleteConsulta={deletarConsulta}
|
||
onAlterarStatus={alterarStatusConsulta}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === "agenda" && (
|
||
<AgendaSection
|
||
medicos={agendaDoctors.map(normalizeMedico)}
|
||
selectedDoctorId={selectedDoctorAgenda}
|
||
onSelectDoctor={setSelectedDoctorAgenda}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === "relatorios" && (
|
||
<RelatoriosSection
|
||
relatorios={relatorios.map(normalizeRelatorio)}
|
||
pacientes={pacientes}
|
||
loading={loadingRelatorios}
|
||
onNovoRelatorio={() => {
|
||
setReportModalMode("create");
|
||
setEditingReportId(null);
|
||
setReportForm({
|
||
patientId: "",
|
||
orderNumber: generateDefaultOrderNumber(),
|
||
exam: "",
|
||
diagnosis: "",
|
||
conclusion: "",
|
||
dueAt: "",
|
||
status: "draft",
|
||
});
|
||
setReportModalOpen(true);
|
||
}}
|
||
onVerDetalhes={openReportDetails}
|
||
onEditarRelatorio={async (id: string) => {
|
||
setReportModalMode("edit");
|
||
setEditingReportId(id);
|
||
const r = await reportService.getById(id);
|
||
setReportForm({
|
||
patientId: r.patient_id || "",
|
||
orderNumber: r.order_number || "",
|
||
exam: r.exam || "",
|
||
diagnosis: r.diagnosis || "",
|
||
conclusion: r.conclusion || "",
|
||
dueAt: r.due_at ? r.due_at.slice(0, 10) : "",
|
||
status: (r.status &&
|
||
["draft", "pending", "completed", "cancelled"].includes(
|
||
r.status
|
||
)
|
||
? r.status
|
||
: "draft") as "draft" | "pending" | "completed" | "cancelled",
|
||
});
|
||
setReportModalOpen(true);
|
||
}}
|
||
/>
|
||
)}
|
||
</main>
|
||
|
||
{patientModalOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="patient-modal-title"
|
||
>
|
||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] flex flex-col">
|
||
<div className="p-6 border-b">
|
||
<div className="flex justify-between items-center">
|
||
<h3 id="patient-modal-title" className="text-lg font-semibold">
|
||
{patientModalMode === "create"
|
||
? "Cadastrar Novo Paciente"
|
||
: "Editar Paciente"}
|
||
</h3>
|
||
<button
|
||
onClick={closePacienteModal}
|
||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-400"
|
||
aria-label="Fechar modal"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
Preencha todos os campos obrigatórios (*)
|
||
</p>
|
||
</div>
|
||
<div className="p-6 overflow-y-auto flex-1">
|
||
<form
|
||
onSubmit={handleSubmitPaciente}
|
||
className="space-y-4 max-h-[70vh] overflow-y-auto px-1"
|
||
>
|
||
{/* Seção: Dados Pessoais */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-green-600 uppercase tracking-wide border-b pb-1">
|
||
Dados Pessoais
|
||
</h4>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nome Completo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.nome}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
nome: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
placeholder="Maria Santos Silva"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nome Social
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.social_name}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
social_name: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Maria Santos"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
CPF *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.cpf}
|
||
onChange={handleCpfChange}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
placeholder="000.000.000-00"
|
||
maxLength={14}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Data de Nascimento *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={formDataPaciente.dataNascimento}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
dataNascimento: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Sexo *
|
||
</label>
|
||
<select
|
||
value={formDataPaciente.sexo}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
sexo: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
>
|
||
<option value="">Selecione</option>
|
||
<option value="M">Masculino</option>
|
||
<option value="F">Feminino</option>
|
||
<option value="outro">Outro</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Contato */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-green-600 uppercase tracking-wide border-b pb-1">
|
||
Contato
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Email *
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={formDataPaciente.email}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
email: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
placeholder="maria@email.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Telefone Celular *
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={formDataPaciente.codigoPais}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
codigoPais: event.target.value,
|
||
}))
|
||
}
|
||
className="w-24 px-2 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm"
|
||
required
|
||
>
|
||
{COUNTRY_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.ddd}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
ddd: event.target.value
|
||
.replace(/\D/g, "")
|
||
.slice(0, 2),
|
||
}))
|
||
}
|
||
className="w-16 px-2 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm text-center"
|
||
placeholder="11"
|
||
required
|
||
/>
|
||
<input
|
||
type="tel"
|
||
value={formDataPaciente.numeroTelefone}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
numeroTelefone: event.target.value
|
||
.replace(/\D/g, "")
|
||
.slice(0, 9),
|
||
}))
|
||
}
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="99999-9999"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Informações Clínicas */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-green-600 uppercase tracking-wide border-b pb-1">
|
||
Informações Clínicas
|
||
</h4>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Tipo Sanguíneo
|
||
</label>
|
||
<select
|
||
value={formDataPaciente.tipo_sanguineo}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
tipo_sanguineo: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
>
|
||
<option value="">Selecione</option>
|
||
{BLOOD_TYPES.map((tipo) => (
|
||
<option key={tipo} value={tipo}>
|
||
{tipo}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Peso (kg)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="10"
|
||
max="300"
|
||
step="0.1"
|
||
value={formDataPaciente.peso}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
peso: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="65.5"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Altura (m)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0.5"
|
||
max="2.5"
|
||
step="0.01"
|
||
value={formDataPaciente.altura}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
altura: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="1.65"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Convênio
|
||
</label>
|
||
<select
|
||
value={formDataPaciente.convenio}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
convenio: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
>
|
||
<option value="">Selecione</option>
|
||
{CONVENIOS.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Número da Carteirinha
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.numeroCarteirinha}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
numeroCarteirinha: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Número da carteirinha"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Endereço */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-green-600 uppercase tracking-wide border-b pb-1">
|
||
Endereço
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
CEP
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={maskCep(formDataPaciente.endereco.cep || "")}
|
||
onChange={(event) => {
|
||
const digits = event.target.value
|
||
.replace(/\D/g, "")
|
||
.slice(0, 8);
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
cep: digits,
|
||
},
|
||
}));
|
||
}}
|
||
onBlur={(event) => {
|
||
const digits = event.target.value.replace(/\D/g, "");
|
||
if (digits.length === 8) {
|
||
void handleCepLookup(digits);
|
||
}
|
||
}}
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="01234-567"
|
||
maxLength={9}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleCepLookup(formDataPaciente.endereco.cep || "")
|
||
}
|
||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors shadow-sm hover:shadow-md"
|
||
title="Buscar endereço pelo CEP"
|
||
>
|
||
<span className="text-lg">✓</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Rua
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.rua}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
rua: event.target.value,
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Rua das Flores"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Número
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.numero}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
numero: event.target.value,
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="123"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Bairro
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.bairro}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
bairro: event.target.value,
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Centro"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Cidade
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.cidade}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
cidade: event.target.value,
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="São Paulo"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Estado
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.estado}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
estado: event.target.value.toUpperCase(),
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="SP"
|
||
maxLength={2}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Complemento
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataPaciente.endereco.complemento}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
endereco: {
|
||
...prev.endereco,
|
||
complemento: event.target.value,
|
||
},
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Apto 45, Bloco B..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Observações */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wide border-b pb-1">
|
||
Observações Adicionais
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Observações
|
||
</label>
|
||
<textarea
|
||
value={formDataPaciente.observacoes}
|
||
onChange={(event) =>
|
||
setFormDataPaciente((prev) => ({
|
||
...prev,
|
||
observacoes: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
rows={3}
|
||
placeholder="Observações gerais sobre o paciente..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-4 border-t mt-6 sticky bottom-0 bg-white">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetPacienteForm();
|
||
closePacienteModal();
|
||
}}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-green-500"
|
||
>
|
||
{loading
|
||
? "Salvando..."
|
||
: patientModalMode === "create"
|
||
? "Cadastrar Paciente"
|
||
: "Salvar Alterações"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Criar Relatório */}
|
||
{reportModalOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="report-modal-title"
|
||
>
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||
<div className="p-6 border-b flex items-center justify-between">
|
||
<h3 id="report-modal-title" className="text-lg font-semibold">
|
||
{reportModalMode === "create"
|
||
? "Novo Relatório"
|
||
: "Editar Relatório"}
|
||
</h3>
|
||
<button
|
||
onClick={() => setReportModalOpen(false)}
|
||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-400"
|
||
aria-label="Fechar modal"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
<form
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
if (!reportForm.patientId) {
|
||
toast.error("Selecione o paciente");
|
||
return;
|
||
}
|
||
if (!reportForm.orderNumber.trim()) {
|
||
toast.error("Informe o número do pedido");
|
||
return;
|
||
}
|
||
if (!ORDER_NUMBER_PATTERN.test(reportForm.orderNumber.trim())) {
|
||
toast.error(
|
||
"Formato inválido. Use REL-YYYY-MM-XXXXXX (ex.: REL-2025-10-MUS3TN)"
|
||
);
|
||
return;
|
||
}
|
||
setReportSaving(true);
|
||
const dueAtIso = reportForm.dueAt
|
||
? `${reportForm.dueAt}T00:00:00`
|
||
: undefined;
|
||
try {
|
||
if (reportModalMode === "create") {
|
||
await reportService.create({
|
||
patient_id: reportForm.patientId,
|
||
exam: reportForm.exam || undefined,
|
||
diagnosis: reportForm.diagnosis || undefined,
|
||
conclusion: reportForm.conclusion || undefined,
|
||
due_at: dueAtIso,
|
||
status: reportForm.status,
|
||
});
|
||
toast.success("Relatório criado");
|
||
} else if (editingReportId) {
|
||
await reportService.update(editingReportId, {
|
||
patient_id: reportForm.patientId,
|
||
exam: reportForm.exam || undefined,
|
||
diagnosis: reportForm.diagnosis || undefined,
|
||
conclusion: reportForm.conclusion || undefined,
|
||
due_at: dueAtIso,
|
||
status: reportForm.status,
|
||
});
|
||
toast.success("Relatório atualizado");
|
||
}
|
||
setReportModalOpen(false);
|
||
setEditingReportId(null);
|
||
await carregarRelatorios();
|
||
} catch (error) {
|
||
toast.error("Erro ao salvar relatório");
|
||
} finally {
|
||
setReportSaving(false);
|
||
}
|
||
}}
|
||
className="p-6 space-y-4 overflow-y-auto"
|
||
>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Paciente *
|
||
</label>
|
||
<select
|
||
value={reportForm.patientId}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
patientId: e.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
>
|
||
<option value="">-- Selecione --</option>
|
||
{pacientes.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.nome}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Número do Pedido *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={reportForm.orderNumber}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
orderNumber: e.target.value.toUpperCase(),
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
required
|
||
placeholder="Ex: REL-2025-10-MUS3TN"
|
||
pattern="^REL-\d{4}-\d{2}-[A-Z0-9]{6}$"
|
||
title="Formato: REL-YYYY-MM-XXXXXX (ex.: REL-2025-10-MUS3TN)"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Exame
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={reportForm.exam}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
exam: e.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
placeholder="Ex: Hemograma"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Prazo (Data)
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={reportForm.dueAt}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
dueAt: e.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Diagnóstico
|
||
</label>
|
||
<textarea
|
||
value={reportForm.diagnosis}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
diagnosis: e.target.value,
|
||
}))
|
||
}
|
||
rows={3}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Conclusão
|
||
</label>
|
||
<textarea
|
||
value={reportForm.conclusion}
|
||
onChange={(e) =>
|
||
setReportForm((prev) => ({
|
||
...prev,
|
||
conclusion: e.target.value,
|
||
}))
|
||
}
|
||
rows={3}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-3 border-t pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setReportModalOpen(false)}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
||
disabled={reportSaving}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-green-500"
|
||
disabled={reportSaving}
|
||
>
|
||
{reportSaving
|
||
? "Salvando..."
|
||
: reportModalMode === "create"
|
||
? "Criar Relatório"
|
||
: "Salvar Alterações"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Detalhes do Relatório */}
|
||
{reportDetailsOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="report-details-title"
|
||
>
|
||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6 border-b flex items-center justify-between">
|
||
<h3 id="report-details-title" className="text-lg font-semibold">
|
||
Detalhes do Relatório
|
||
</h3>
|
||
<button
|
||
onClick={() => setReportDetailsOpen(false)}
|
||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-400"
|
||
aria-label="Fechar modal"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
<div className="p-6 space-y-4">
|
||
{reportDetailsLoading ? (
|
||
<div className="flex justify-center py-8">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||
</div>
|
||
) : !reportDetails ? (
|
||
<div className="text-center text-sm text-gray-500 py-8">
|
||
Relatório não encontrado.
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<div className="text-xs text-gray-500">Número</div>
|
||
<div className="text-sm font-medium">
|
||
{reportDetails.order_number || "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">Paciente</div>
|
||
<div className="text-sm font-medium">
|
||
{pacientes.find(
|
||
(p) => p.id === reportDetails.patient_id
|
||
)?.nome ||
|
||
reportDetails.patient_id ||
|
||
"-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">Status</div>
|
||
<div className="text-sm font-medium capitalize">
|
||
{reportDetails.status || "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">Prazo</div>
|
||
<div className="text-sm font-medium">
|
||
{reportDetails.due_at
|
||
? new Date(reportDetails.due_at).toLocaleDateString(
|
||
"pt-BR"
|
||
)
|
||
: "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">Criado em</div>
|
||
<div className="text-sm font-medium">
|
||
{reportDetails.created_at
|
||
? new Date(reportDetails.created_at).toLocaleString(
|
||
"pt-BR"
|
||
)
|
||
: "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">Atualizado em</div>
|
||
<div className="text-sm font-medium">
|
||
{reportDetails.updated_at
|
||
? new Date(reportDetails.updated_at).toLocaleString(
|
||
"pt-BR"
|
||
)
|
||
: "-"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<div className="text-xs text-gray-500">Exame</div>
|
||
<div className="text-sm">{reportDetails.exam || "-"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500">CID</div>
|
||
<div className="text-sm">
|
||
{reportDetails.cid_code || "-"}
|
||
</div>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<div className="text-xs text-gray-500">Diagnóstico</div>
|
||
<div className="text-sm whitespace-pre-wrap">
|
||
{reportDetails.diagnosis || "-"}
|
||
</div>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<div className="text-xs text-gray-500">Conclusão</div>
|
||
<div className="text-sm whitespace-pre-wrap">
|
||
{reportDetails.conclusion || "-"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{reportDetails.content_html && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs text-gray-500">Conteúdo</div>
|
||
<div
|
||
className="prose max-w-none border rounded p-3 bg-gray-50"
|
||
dangerouslySetInnerHTML={{
|
||
__html: reportDetails.content_html,
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{reportDetails.content_json && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs text-gray-500">
|
||
Conteúdo (JSON)
|
||
</div>
|
||
<pre className="text-xs bg-gray-100 p-3 rounded overflow-auto">
|
||
{JSON.stringify(reportDetails.content_json, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="p-6 border-t flex justify-end">
|
||
<button
|
||
onClick={() => setReportDetailsOpen(false)}
|
||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-500"
|
||
>
|
||
Fechar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{doctorModalOpen && (
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="doctor-modal-title"
|
||
>
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||
<div className="p-6 border-b">
|
||
<div className="flex justify-between items-center">
|
||
<h3 id="doctor-modal-title" className="text-lg font-semibold">
|
||
{doctorModalMode === "create"
|
||
? "Cadastrar Novo Médico"
|
||
: "Editar Médico"}
|
||
</h3>
|
||
<button
|
||
onClick={closeMedicoModal}
|
||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-400"
|
||
aria-label="Fechar modal"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
Preencha todos os campos obrigatórios (*)
|
||
</p>
|
||
</div>
|
||
<div className="p-6 overflow-y-auto flex-1">
|
||
<form
|
||
onSubmit={handleSubmitMedico}
|
||
className="space-y-4 max-h-[70vh] overflow-y-auto px-1"
|
||
>
|
||
{/* Seção: Dados Pessoais */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-blue-600 uppercase tracking-wide border-b pb-1">
|
||
Dados Pessoais
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nome Completo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.nome}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
nome: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
placeholder="Dr. João da Silva"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
CPF *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={maskCpf(formDataMedico.cpf).formatted}
|
||
onChange={(event) => {
|
||
const { digits } = maskCpf(event.target.value);
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
cpf: digits,
|
||
}));
|
||
}}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
placeholder="000.000.000-00"
|
||
maxLength={14}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
RG
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.rg}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
rg: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="00.000.000-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Data de Nascimento *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={formDataMedico.dataNascimento}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
dataNascimento: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Dados Profissionais */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-blue-600 uppercase tracking-wide border-b pb-1">
|
||
Dados Profissionais
|
||
</h4>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
CRM *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.crm}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
crm: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
placeholder="123456"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
UF do CRM *
|
||
</label>
|
||
<select
|
||
value={formDataMedico.crmUf}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
crmUf: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
>
|
||
<option value="">Selecione</option>
|
||
{[
|
||
"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",
|
||
].map((uf) => (
|
||
<option key={uf} value={uf}>
|
||
{uf}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Especialidade *
|
||
</label>
|
||
<select
|
||
value={formDataMedico.especialidade}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
especialidade: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
>
|
||
<option value="">Selecione</option>
|
||
{[
|
||
"Cardiologia",
|
||
"Dermatologia",
|
||
"Endocrinologia",
|
||
"Gastroenterologia",
|
||
"Ginecologia",
|
||
"Neurologia",
|
||
"Ortopedia",
|
||
"Pediatria",
|
||
"Psiquiatria",
|
||
"Clínico Geral",
|
||
].map((especialidade) => (
|
||
<option key={especialidade} value={especialidade}>
|
||
{especialidade}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Contato */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-blue-600 uppercase tracking-wide border-b pb-1">
|
||
Contato
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Email *
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={formDataMedico.email}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
email: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
placeholder="medico@email.com"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Telefone Principal *
|
||
</label>
|
||
<input
|
||
type="tel"
|
||
value={formDataMedico.telefone}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
telefone: buildMedicoTelefone(event.target.value),
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
placeholder="(11) 99999-9999"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Telefone Secundário
|
||
</label>
|
||
<input
|
||
type="tel"
|
||
value={formDataMedico.telefone2}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
telefone2: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="(11) 3333-4444"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Endereço (obrigatório) */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-blue-600 uppercase tracking-wide border-b pb-1">
|
||
Endereço
|
||
</h4>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
CEP *
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={maskCep(formDataMedico.cep)}
|
||
onChange={(event) => {
|
||
const digits = event.target.value
|
||
.replace(/\D/g, "")
|
||
.slice(0, 8);
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
cep: digits,
|
||
}));
|
||
}}
|
||
onBlur={(event) => {
|
||
const digits = event.target.value.replace(/\D/g, "");
|
||
if (digits.length === 8) {
|
||
void handleCepLookupMedico(digits);
|
||
}
|
||
}}
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="00000-000"
|
||
required
|
||
maxLength={9}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleCepLookupMedico(formDataMedico.cep)
|
||
}
|
||
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors shadow-sm hover:shadow-md"
|
||
title="Buscar endereço pelo CEP"
|
||
>
|
||
<span className="text-lg">✓</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Rua *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.rua}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
rua: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Nome da rua"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Número *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.numero}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
numero: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="123"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Bairro *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.bairro}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
bairro: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Bairro"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Cidade *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.cidade}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
cidade: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Cidade"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Estado *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.estado}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
estado: event.target.value.toUpperCase(),
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="UF"
|
||
maxLength={2}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Complemento
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formDataMedico.complemento}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
complemento: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Apto, sala, bloco..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Seção: Senha (apenas criação) */}
|
||
{doctorModalMode === "create" && (
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold text-blue-600 uppercase tracking-wide border-b pb-1">
|
||
Acesso ao Sistema
|
||
</h4>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Senha Provisória *
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formDataMedico.senha}
|
||
onChange={(event) =>
|
||
setFormDataMedico((prev) => ({
|
||
...prev,
|
||
senha: event.target.value,
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
required
|
||
minLength={6}
|
||
placeholder="Mínimo 6 caracteres"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
O médico deverá alterar esta senha no primeiro acesso
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{doctorModalMode === "edit" && (
|
||
<div className="bg-yellow-50 p-3 rounded-lg text-sm text-yellow-800">
|
||
A senha permanece inalterada neste fluxo. Se necessário,
|
||
solicite redefinição pelo suporte.
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-3 pt-4 border-t mt-6 sticky bottom-0 bg-white">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetMedicoForm();
|
||
closeMedicoModal();
|
||
}}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
|
||
>
|
||
{loading
|
||
? "Salvando..."
|
||
: doctorModalMode === "create"
|
||
? "Cadastrar Médico"
|
||
: "Salvar Alterações"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal de Agendamento */}
|
||
<ScheduleAppointmentModal
|
||
isOpen={scheduleModalOpen}
|
||
onClose={() => setScheduleModalOpen(false)}
|
||
patientId={schedulePatientId}
|
||
patientName={schedulePatientName}
|
||
onSuccess={() => {
|
||
carregarAgendamentos();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PainelSecretaria;
|