develop #83
@ -1,421 +1,251 @@
|
|||||||
"use client"
|
/* src/app/dashboard/pacientes/page.tsx */
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
MoreHorizontal,
|
|
||||||
Calendar,
|
|
||||||
Gift,
|
|
||||||
Eye,
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
CalendarPlus,
|
|
||||||
ArrowLeft,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { PatientRegistrationForm } from "@/components/forms/patient-registration-form"
|
|
||||||
|
|
||||||
const patients = [
|
import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
|
||||||
{
|
import { PatientRegistrationForm } from "@/components/forms/patient-registration-form";
|
||||||
id: 1,
|
|
||||||
name: "Aaron Avalos Perez",
|
// Converte qualquer formato que vier do mock para o shape do nosso tipo Paciente
|
||||||
phone: "(75) 99982-6363",
|
function normalizePaciente(p: any): Paciente {
|
||||||
city: "Aracaju",
|
const endereco: Endereco = {
|
||||||
state: "Sergipe",
|
cep: p.endereco?.cep ?? p.cep ?? "",
|
||||||
lastAppointment: "26/09/2025 14:30",
|
logradouro: p.endereco?.logradouro ?? p.logradouro ?? "",
|
||||||
nextAppointment: "19/08/2025 15:00",
|
numero: p.endereco?.numero ?? p.numero ?? "",
|
||||||
isVip: false,
|
complemento: p.endereco?.complemento ?? p.complemento ?? "",
|
||||||
convenio: "unimed",
|
bairro: p.endereco?.bairro ?? p.bairro ?? "",
|
||||||
birthday: "1985-03-15",
|
cidade: p.endereco?.cidade ?? p.cidade ?? "",
|
||||||
age: 40,
|
estado: p.endereco?.estado ?? p.estado ?? "",
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: 2,
|
return {
|
||||||
name: "ABENANDO OLIVEIRA DE JESUS",
|
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
|
||||||
phone: "(75) 99986-0093",
|
nome: p.nome ?? "",
|
||||||
city: "-",
|
nome_social: p.nome_social ?? null,
|
||||||
state: "-",
|
cpf: p.cpf ?? "",
|
||||||
lastAppointment: "Ainda não houve atendimento",
|
rg: p.rg ?? null,
|
||||||
nextAppointment: "Nenhum atendimento agendado",
|
sexo: p.sexo ?? null,
|
||||||
isVip: false,
|
data_nascimento: p.data_nascimento ?? null,
|
||||||
convenio: "particular",
|
telefone: p.telefone ?? "",
|
||||||
birthday: "1978-12-03",
|
email: p.email ?? "",
|
||||||
age: 46,
|
endereco,
|
||||||
},
|
observacoes: p.observacoes ?? null,
|
||||||
{
|
foto_url: p.foto_url ?? null,
|
||||||
id: 3,
|
};
|
||||||
name: "ABDIAS DANTAS DOS SANTOS",
|
}
|
||||||
phone: "(75) 99125-7267",
|
|
||||||
city: "São Cristóvão",
|
|
||||||
state: "Sergipe",
|
|
||||||
lastAppointment: "30/12/2024 08:40",
|
|
||||||
nextAppointment: "Nenhum atendimento agendado",
|
|
||||||
isVip: true,
|
|
||||||
convenio: "bradesco",
|
|
||||||
birthday: "1990-12-03",
|
|
||||||
age: 34,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Abdias Matheus Rodrigues Ferreira",
|
|
||||||
phone: "(75) 99983-7711",
|
|
||||||
city: "Pirambu",
|
|
||||||
state: "Sergipe",
|
|
||||||
lastAppointment: "04/09/2024 16:20",
|
|
||||||
nextAppointment: "Nenhum atendimento agendado",
|
|
||||||
isVip: false,
|
|
||||||
convenio: "amil",
|
|
||||||
birthday: "1982-12-03",
|
|
||||||
age: 42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "Abdon Ferreira Guerra",
|
|
||||||
phone: "(75) 99971-0228",
|
|
||||||
city: "-",
|
|
||||||
state: "-",
|
|
||||||
lastAppointment: "08/05/2025 08:00",
|
|
||||||
nextAppointment: "Nenhum atendimento agendado",
|
|
||||||
isVip: false,
|
|
||||||
convenio: "unimed",
|
|
||||||
birthday: "1975-12-03",
|
|
||||||
age: 49,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function PacientesPage() {
|
export default function PacientesPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [patients, setPatients] = useState<Paciente[]>([]);
|
||||||
const [selectedConvenio, setSelectedConvenio] = useState("all")
|
const [loading, setLoading] = useState(true);
|
||||||
const [showVipOnly, setShowVipOnly] = useState(false)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showBirthdays, setShowBirthdays] = useState(false)
|
|
||||||
const [showPatientForm, setShowPatientForm] = useState(false)
|
|
||||||
const [editingPatient, setEditingPatient] = useState<number | null>(null)
|
|
||||||
const [advancedFilters, setAdvancedFilters] = useState({
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
minAge: "",
|
|
||||||
maxAge: "",
|
|
||||||
lastAppointmentFrom: "",
|
|
||||||
lastAppointmentTo: "",
|
|
||||||
})
|
|
||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false)
|
|
||||||
|
|
||||||
const filteredPatients = patients.filter((patient) => {
|
const [search, setSearch] = useState("");
|
||||||
const matchesSearch =
|
const [showForm, setShowForm] = useState(false);
|
||||||
patient.name.toLowerCase().includes(searchTerm.toLowerCase()) || patient.phone.includes(searchTerm)
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const matchesConvenio = selectedConvenio === "all" || patient.convenio === selectedConvenio
|
async function loadAll() {
|
||||||
const matchesVip = !showVipOnly || patient.isVip
|
try {
|
||||||
|
setLoading(true);
|
||||||
const currentMonth = new Date().getMonth() + 1
|
const data = await listarPacientes({ page: 1, limit: 20 });
|
||||||
const patientBirthMonth = new Date(patient.birthday).getMonth() + 1
|
setPatients((data ?? []).map(normalizePaciente));
|
||||||
const matchesBirthday = !showBirthdays || patientBirthMonth === currentMonth
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
const matchesCity = !advancedFilters.city || patient.city.toLowerCase().includes(advancedFilters.city.toLowerCase())
|
setPatients([]);
|
||||||
const matchesState =
|
setError(e?.message || "Erro ao carregar pacientes.");
|
||||||
!advancedFilters.state || patient.state.toLowerCase().includes(advancedFilters.state.toLowerCase())
|
} finally {
|
||||||
const matchesMinAge = !advancedFilters.minAge || patient.age >= Number.parseInt(advancedFilters.minAge)
|
setLoading(false);
|
||||||
const matchesMaxAge = !advancedFilters.maxAge || patient.age <= Number.parseInt(advancedFilters.maxAge)
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
matchesSearch &&
|
|
||||||
matchesConvenio &&
|
|
||||||
matchesVip &&
|
|
||||||
matchesBirthday &&
|
|
||||||
matchesCity &&
|
|
||||||
matchesState &&
|
|
||||||
matchesMinAge &&
|
|
||||||
matchesMaxAge
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const clearAdvancedFilters = () => {
|
|
||||||
setAdvancedFilters({
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
minAge: "",
|
|
||||||
maxAge: "",
|
|
||||||
lastAppointmentFrom: "",
|
|
||||||
lastAppointmentTo: "",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewDetails = (patientId: number) => {
|
useEffect(() => {
|
||||||
console.log("[v0] Ver detalhes do paciente:", patientId)
|
loadAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search.trim()) return patients;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const qDigits = q.replace(/\D/g, "");
|
||||||
|
return patients.filter((p) => {
|
||||||
|
const byName = (p.nome || "").toLowerCase().includes(q);
|
||||||
|
const byCPF = (p.cpf || "").replace(/\D/g, "").includes(qDigits);
|
||||||
|
const byId = String(p.id || "").includes(qDigits);
|
||||||
|
return byName || byCPF || byId;
|
||||||
|
});
|
||||||
|
}, [patients, search]);
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPatient = (patientId: number) => {
|
function handleEdit(id: string) {
|
||||||
console.log("[v0] Editar paciente:", patientId)
|
setEditingId(id);
|
||||||
setEditingPatient(patientId)
|
setShowForm(true);
|
||||||
setShowPatientForm(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScheduleAppointment = (patientId: number) => {
|
async function handleDelete(id: string) {
|
||||||
console.log("[v0] Marcar consulta para paciente:", patientId)
|
if (!confirm("Excluir este paciente?")) return;
|
||||||
|
try {
|
||||||
|
await excluirPaciente(id);
|
||||||
|
setPatients((prev) => prev.filter((x) => String(x.id) !== String(id)));
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || "Não foi possível excluir.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePatient = (patientId: number) => {
|
function handleSaved(p: Paciente) {
|
||||||
console.log("[v0] Excluir paciente:", patientId)
|
const saved = normalizePaciente(p);
|
||||||
|
setPatients((prev) => {
|
||||||
|
const i = prev.findIndex((x) => String(x.id) === String(saved.id));
|
||||||
|
if (i < 0) return [saved, ...prev];
|
||||||
|
const clone = [...prev];
|
||||||
|
clone[i] = saved;
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
setShowForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPatient = () => {
|
async function handleBuscarServidor() {
|
||||||
setEditingPatient(null)
|
const q = search.trim();
|
||||||
setShowPatientForm(true)
|
if (!q) return loadAll();
|
||||||
|
|
||||||
|
// Se for apenas números, tentamos como ID no servidor
|
||||||
|
if (/^\d+$/.test(q)) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const one = await buscarPacientePorId(q);
|
||||||
|
setPatients(one ? [normalizePaciente(one)] : []);
|
||||||
|
setError(one ? null : "Paciente não encontrado.");
|
||||||
|
} catch (e: any) {
|
||||||
|
setPatients([]);
|
||||||
|
setError(e?.message || "Paciente não encontrado.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Senão, recarrega e filtra local (o mock nem sempre filtra por nome/CPF)
|
||||||
|
await loadAll();
|
||||||
|
setTimeout(() => setSearch(q), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFormClose = () => {
|
if (loading) return <p>Carregando pacientes...</p>;
|
||||||
setShowPatientForm(false)
|
if (error) return <p className="text-red-500">{error}</p>;
|
||||||
setEditingPatient(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showPatientForm) {
|
if (showForm) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={handleFormClose} className="p-2">
|
<Button variant="ghost" onClick={() => setShowForm(false)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<h1 className="text-2xl font-bold">{editingId ? "Editar paciente" : "Novo paciente"}</h1>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
|
||||||
{editingPatient ? "Editar Paciente" : "Cadastrar Novo Paciente"}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{editingPatient ? "Atualize as informações do paciente" : "Preencha os dados do novo paciente"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PatientRegistrationForm patientId={editingPatient} onClose={handleFormClose} inline={true} />
|
<PatientRegistrationForm
|
||||||
|
inline
|
||||||
|
mode={editingId ? "edit" : "create"}
|
||||||
|
patientId={editingId ? Number(editingId) : null}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Cabeçalho + Busca (um único input no padrão do print) */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
|
<h1 className="text-2xl font-bold">Pacientes</h1>
|
||||||
<p className="text-muted-foreground">Gerencie as informações de seus pacientes</p>
|
<p className="text-muted-foreground">Gerencie os pacientes</p>
|
||||||
</div>
|
|
||||||
<Button className="bg-primary hover:bg-primary/90" onClick={handleAddPatient}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Adicionar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
|
||||||
<div className="relative flex-1 min-w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar paciente"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={selectedConvenio} onValueChange={setSelectedConvenio}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger className="w-48">
|
<div className="relative">
|
||||||
<SelectValue placeholder="Selecione o Convênio" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
</SelectTrigger>
|
<Input
|
||||||
<SelectContent>
|
className="pl-8 w-80"
|
||||||
<SelectItem value="all">Todos os Convênios</SelectItem>
|
placeholder="Buscar por nome, CPF ou ID…"
|
||||||
<SelectItem value="unimed">Unimed</SelectItem>
|
value={search}
|
||||||
<SelectItem value="bradesco">Bradesco Saúde</SelectItem>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<SelectItem value="amil">Amil</SelectItem>
|
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
|
||||||
<SelectItem value="particular">Particular</SelectItem>
|
/>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
<Button variant="secondary" onClick={handleBuscarServidor}>Buscar</Button>
|
||||||
|
<Button onClick={handleAdd}>
|
||||||
<Button
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
variant={showVipOnly ? "default" : "outline"}
|
Novo paciente
|
||||||
onClick={() => setShowVipOnly(!showVipOnly)}
|
</Button>
|
||||||
className="flex items-center gap-2"
|
</div>
|
||||||
>
|
|
||||||
<Gift className="h-4 w-4" />
|
|
||||||
VIP
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={showBirthdays ? "default" : "outline"}
|
|
||||||
onClick={() => setShowBirthdays(!showBirthdays)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Aniversariantes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog open={isAdvancedFilterOpen} onOpenChange={setIsAdvancedFilterOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" className="flex items-center gap-2 bg-transparent">
|
|
||||||
<Filter className="h-4 w-4" />
|
|
||||||
Filtro avançado
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Filtros Avançados</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Use os filtros abaixo para refinar sua busca por pacientes específicos.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="city">Cidade</Label>
|
|
||||||
<Input
|
|
||||||
id="city"
|
|
||||||
value={advancedFilters.city}
|
|
||||||
onChange={(e) => setAdvancedFilters((prev) => ({ ...prev, city: e.target.value }))}
|
|
||||||
placeholder="Digite a cidade"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="state">Estado</Label>
|
|
||||||
<Input
|
|
||||||
id="state"
|
|
||||||
value={advancedFilters.state}
|
|
||||||
onChange={(e) => setAdvancedFilters((prev) => ({ ...prev, state: e.target.value }))}
|
|
||||||
placeholder="Digite o estado"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="minAge">Idade mínima</Label>
|
|
||||||
<Input
|
|
||||||
id="minAge"
|
|
||||||
type="number"
|
|
||||||
value={advancedFilters.minAge}
|
|
||||||
onChange={(e) => setAdvancedFilters((prev) => ({ ...prev, minAge: e.target.value }))}
|
|
||||||
placeholder="Ex: 18"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxAge">Idade máxima</Label>
|
|
||||||
<Input
|
|
||||||
id="maxAge"
|
|
||||||
type="number"
|
|
||||||
value={advancedFilters.maxAge}
|
|
||||||
onChange={(e) => setAdvancedFilters((prev) => ({ ...prev, maxAge: e.target.value }))}
|
|
||||||
placeholder="Ex: 65"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button onClick={clearAdvancedFilters} variant="outline" className="flex-1 bg-transparent">
|
|
||||||
Limpar Filtros
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setIsAdvancedFilterOpen(false)} className="flex-1">
|
|
||||||
Aplicar Filtros
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nome</TableHead>
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>CPF</TableHead>
|
||||||
<TableHead>Telefone</TableHead>
|
<TableHead>Telefone</TableHead>
|
||||||
<TableHead>Cidade</TableHead>
|
<TableHead>Cidade</TableHead>
|
||||||
<TableHead>Estado</TableHead>
|
<TableHead>Estado</TableHead>
|
||||||
<TableHead>Último atendimento</TableHead>
|
|
||||||
<TableHead>Próximo atendimento</TableHead>
|
|
||||||
<TableHead className="w-[100px]">Ações</TableHead>
|
<TableHead className="w-[100px]">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredPatients.map((patient) => (
|
{filtered.length > 0 ? (
|
||||||
<TableRow key={patient.id}>
|
filtered.map((p) => (
|
||||||
<TableCell className="font-medium">
|
<TableRow key={p.id}>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell className="font-medium">{p.nome || "(sem nome)"}</TableCell>
|
||||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
<TableCell>{p.cpf || "-"}</TableCell>
|
||||||
<span className="text-xs font-medium">{patient.name.charAt(0).toUpperCase()}</span>
|
<TableCell>{p.telefone || "-"}</TableCell>
|
||||||
</div>
|
<TableCell>{p.endereco?.cidade || "-"}</TableCell>
|
||||||
<button onClick={() => handleViewDetails(patient.id)} className="hover:text-primary cursor-pointer">
|
<TableCell>{p.endereco?.estado || "-"}</TableCell>
|
||||||
{patient.name}
|
<TableCell>
|
||||||
</button>
|
<DropdownMenu>
|
||||||
{patient.isVip && (
|
<DropdownMenuTrigger asChild>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
|
||||||
VIP
|
<span className="sr-only">Abrir menu</span>
|
||||||
</Badge>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
</DropdownMenuTrigger>
|
||||||
</TableCell>
|
<DropdownMenuContent align="end">
|
||||||
<TableCell>{patient.phone}</TableCell>
|
<DropdownMenuItem onClick={() => alert(JSON.stringify(p, null, 2))}>
|
||||||
<TableCell>{patient.city}</TableCell>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
<TableCell>{patient.state}</TableCell>
|
Ver
|
||||||
<TableCell>
|
</DropdownMenuItem>
|
||||||
<span
|
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
|
||||||
className={patient.lastAppointment === "Ainda não houve atendimento" ? "text-muted-foreground" : ""}
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
>
|
Editar
|
||||||
{patient.lastAppointment}
|
</DropdownMenuItem>
|
||||||
</span>
|
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
|
||||||
</TableCell>
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<TableCell>
|
Excluir
|
||||||
<span
|
</DropdownMenuItem>
|
||||||
className={patient.nextAppointment === "Nenhum atendimento agendado" ? "text-muted-foreground" : ""}
|
</DropdownMenuContent>
|
||||||
>
|
</DropdownMenu>
|
||||||
{patient.nextAppointment}
|
</TableCell>
|
||||||
</span>
|
</TableRow>
|
||||||
</TableCell>
|
))
|
||||||
<TableCell>
|
) : (
|
||||||
<DropdownMenu>
|
<TableRow>
|
||||||
<DropdownMenuTrigger asChild>
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
|
Nenhum paciente encontrado
|
||||||
<span className="sr-only">Abrir menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => handleViewDetails(patient.id)}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Ver detalhes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleEditPatient(patient.id)}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Editar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleScheduleAppointment(patient.id)}>
|
|
||||||
<CalendarPlus className="mr-2 h-4 w-4" />
|
|
||||||
Marcar consulta
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleDeletePatient(patient.id)} className="text-destructive">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Excluir
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
|
||||||
Mostrando {filteredPatients.length} de {patients.length} pacientes
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,13 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Home, Calendar, Users, UserCheck, FileText, BarChart3, Settings, Stethoscope } from "lucide-react"
|
import { Home, Calendar, Users, UserCheck, FileText, BarChart3, Settings, Stethoscope, User } from "lucide-react"
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
||||||
{ name: "Agenda", href: "/dashboard/agenda", icon: Calendar },
|
{ name: "Agenda", href: "/dashboard/agenda", icon: Calendar },
|
||||||
{ name: "Pacientes", href: "/dashboard/pacientes", icon: Users },
|
{ name: "Pacientes", href: "/dashboard/pacientes", icon: Users },
|
||||||
|
{ name: "Médicos", href: "/dashboard/medicos", icon: User },
|
||||||
{ name: "Consultas", href: "/dashboard/consultas", icon: UserCheck },
|
{ name: "Consultas", href: "/dashboard/consultas", icon: UserCheck },
|
||||||
{ name: "Prontuários", href: "/dashboard/prontuarios", icon: FileText },
|
{ name: "Prontuários", href: "/dashboard/prontuarios", icon: FileText },
|
||||||
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,23 +0,0 @@
|
|||||||
export async function salvarPaciente(formData) {
|
|
||||||
var myHeaders = new Headers();
|
|
||||||
myHeaders.append("Content-Type", "application/json");
|
|
||||||
|
|
||||||
var raw = JSON.stringify(formData);
|
|
||||||
|
|
||||||
var requestOptions = {
|
|
||||||
method: 'POST',
|
|
||||||
headers: myHeaders,
|
|
||||||
body: raw,
|
|
||||||
redirect: 'follow'
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://mock.apidog.com/m1/1053378-0-default/pacientes", requestOptions);
|
|
||||||
const result = await response.json();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.log('error', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
255
susconecta/lib/api.ts
Normal file
255
susconecta/lib/api.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/* src/lib/api.ts */
|
||||||
|
|
||||||
|
export type ApiOk<T = any> = {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
pagination?: {
|
||||||
|
current_page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
total_pages?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Endereco = {
|
||||||
|
cep?: string;
|
||||||
|
logradouro?: string;
|
||||||
|
numero?: string;
|
||||||
|
complemento?: string;
|
||||||
|
bairro?: string;
|
||||||
|
cidade?: string;
|
||||||
|
estado?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Paciente = {
|
||||||
|
id: string;
|
||||||
|
nome?: string;
|
||||||
|
nome_social?: string | null;
|
||||||
|
cpf?: string;
|
||||||
|
rg?: string | null;
|
||||||
|
sexo?: string | null;
|
||||||
|
data_nascimento?: string | null;
|
||||||
|
telefone?: string;
|
||||||
|
email?: string;
|
||||||
|
endereco?: Endereco;
|
||||||
|
observacoes?: string | null;
|
||||||
|
foto_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PacienteInput = {
|
||||||
|
nome: string;
|
||||||
|
nome_social?: string | null;
|
||||||
|
cpf: string;
|
||||||
|
rg?: string | null;
|
||||||
|
sexo?: string | null;
|
||||||
|
data_nascimento?: string | null;
|
||||||
|
telefone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
endereco?: {
|
||||||
|
cep?: string | null;
|
||||||
|
logradouro?: string | null;
|
||||||
|
numero?: string | null;
|
||||||
|
complemento?: string | null;
|
||||||
|
bairro?: string | null;
|
||||||
|
cidade?: string | null;
|
||||||
|
estado?: string | null;
|
||||||
|
};
|
||||||
|
observacoes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Config & helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "https://mock.apidog.com/m1/1053378-0-default";
|
||||||
|
|
||||||
|
const PATHS = {
|
||||||
|
pacientes: "/pacientes",
|
||||||
|
pacienteId: (id: string | number) => `/pacientes/${id}`,
|
||||||
|
foto: (id: string | number) => `/pacientes/${id}/foto`,
|
||||||
|
anexos: (id: string | number) => `/pacientes/${id}/anexos`,
|
||||||
|
anexoId: (id: string | number, anexoId: string | number) => `/pacientes/${id}/anexos/${anexoId}`,
|
||||||
|
validarCPF: "/pacientes/validar-cpf",
|
||||||
|
cep: (cep: string) => `/utils/cep/${cep}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function headers(kind: "json" | "form" = "json"): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {};
|
||||||
|
const token = process.env.NEXT_PUBLIC_API_TOKEN?.trim();
|
||||||
|
if (token) h.Authorization = `Bearer ${token}`;
|
||||||
|
if (kind === "json") h["Content-Type"] = "application/json";
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAPI(title: string, info: { url?: string; payload?: any; result?: any } = {}) {
|
||||||
|
try {
|
||||||
|
console.group(`[API] ${title}`);
|
||||||
|
if (info.url) console.log("url:", info.url);
|
||||||
|
if (info.payload !== undefined) console.log("payload:", info.payload);
|
||||||
|
if (info.result !== undefined) console.log("API result:", info.result);
|
||||||
|
console.groupEnd();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parse<T>(res: Response): Promise<T> {
|
||||||
|
let json: any = null;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = json?.apidogError?.code ?? res.status;
|
||||||
|
const msg = json?.apidogError?.message ?? res.statusText;
|
||||||
|
throw new Error(`${code}: ${msg}`);
|
||||||
|
}
|
||||||
|
// muitos endpoints do mock respondem { success, data }
|
||||||
|
return (json?.data ?? json) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Pacientes (CRUD)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listarPacientes(params?: { page?: number; limit?: number; q?: string }): Promise<Paciente[]> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.page) query.set("page", String(params.page));
|
||||||
|
if (params?.limit) query.set("limit", String(params.limit));
|
||||||
|
if (params?.q) query.set("q", params.q);
|
||||||
|
const url = `${API_BASE}${PATHS.pacientes}${query.toString() ? `?${query.toString()}` : ""}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
||||||
|
const data = await parse<ApiOk<Paciente[]>>(res);
|
||||||
|
logAPI("listarPacientes", { url, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
|
||||||
|
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
||||||
|
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
||||||
|
const data = await parse<ApiOk<Paciente>>(res);
|
||||||
|
logAPI("buscarPacientePorId", { url, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
|
||||||
|
const url = `${API_BASE}${PATHS.pacientes}`;
|
||||||
|
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) });
|
||||||
|
const data = await parse<ApiOk<Paciente>>(res);
|
||||||
|
logAPI("criarPaciente", { url, payload: input, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
|
||||||
|
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
||||||
|
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) });
|
||||||
|
const data = await parse<ApiOk<Paciente>>(res);
|
||||||
|
logAPI("atualizarPaciente", { url, payload: input, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function excluirPaciente(id: string | number): Promise<void> {
|
||||||
|
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
||||||
|
await parse<any>(res);
|
||||||
|
logAPI("excluirPaciente", { url, result: { ok: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Foto
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function uploadFotoPaciente(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> {
|
||||||
|
const url = `${API_BASE}${PATHS.foto(id)}`;
|
||||||
|
const fd = new FormData();
|
||||||
|
// nome de campo mais comum no mock
|
||||||
|
fd.append("foto", file);
|
||||||
|
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
|
||||||
|
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res);
|
||||||
|
logAPI("uploadFotoPaciente", { url, payload: { file: file.name }, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removerFotoPaciente(id: string | number): Promise<void> {
|
||||||
|
const url = `${API_BASE}${PATHS.foto(id)}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
||||||
|
await parse<any>(res);
|
||||||
|
logAPI("removerFotoPaciente", { url, result: { ok: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Anexos
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listarAnexos(id: string | number): Promise<any[]> {
|
||||||
|
const url = `${API_BASE}${PATHS.anexos(id)}`;
|
||||||
|
const res = await fetch(url, { method: "GET", headers: headers("json") });
|
||||||
|
const data = await parse<ApiOk<any[]>>(res);
|
||||||
|
logAPI("listarAnexos", { url, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adicionarAnexo(id: string | number, file: File): Promise<any> {
|
||||||
|
const url = `${API_BASE}${PATHS.anexos(id)}`;
|
||||||
|
const fd = new FormData();
|
||||||
|
// alguns mocks usam "arquivo" e outros "file"; tentamos ambos
|
||||||
|
fd.append("arquivo", file);
|
||||||
|
const res = await fetch(url, { method: "POST", body: fd, headers: headers("form") });
|
||||||
|
const data = await parse<ApiOk<any>>(res);
|
||||||
|
logAPI("adicionarAnexo", { url, payload: { file: file.name }, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removerAnexo(id: string | number, anexoId: string | number): Promise<void> {
|
||||||
|
const url = `${API_BASE}${PATHS.anexoId(id, anexoId)}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
|
||||||
|
await parse<any>(res);
|
||||||
|
logAPI("removerAnexo", { url, result: { ok: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Validações
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function validarCPF(cpf: string): Promise<{ valido: boolean; existe: boolean; paciente_id: string | null }> {
|
||||||
|
const url = `${API_BASE}${PATHS.validarCPF}`;
|
||||||
|
const payload = { cpf };
|
||||||
|
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(payload) });
|
||||||
|
const data = await parse<ApiOk<{ valido: boolean; existe: boolean; paciente_id: string | null }>>(res);
|
||||||
|
logAPI("validarCPF", { url, payload, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarCepAPI(cep: string): Promise<{ logradouro?: string; bairro?: string; localidade?: string; uf?: string; erro?: boolean }> {
|
||||||
|
const clean = (cep || "").replace(/\D/g, "");
|
||||||
|
const urlMock = `${API_BASE}${PATHS.cep(clean)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(urlMock, { method: "GET", headers: headers("json") });
|
||||||
|
const data = await parse<any>(res); // pode vir direto ou dentro de {data}
|
||||||
|
logAPI("buscarCEP (mock)", { url: urlMock, payload: { cep: clean }, result: data });
|
||||||
|
const d = data?.data ?? data ?? {};
|
||||||
|
return {
|
||||||
|
logradouro: d.logradouro ?? d.street ?? "",
|
||||||
|
bairro: d.bairro ?? d.neighborhood ?? "",
|
||||||
|
localidade: d.localidade ?? d.city ?? "",
|
||||||
|
uf: d.uf ?? d.state ?? "",
|
||||||
|
erro: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// fallback ViaCEP
|
||||||
|
const urlVia = `https://viacep.com.br/ws/${clean}/json/`;
|
||||||
|
const resV = await fetch(urlVia);
|
||||||
|
const jsonV = await resV.json().catch(() => ({}));
|
||||||
|
logAPI("buscarCEP (ViaCEP/fallback)", { url: urlVia, payload: { cep: clean }, result: jsonV });
|
||||||
|
if (jsonV?.erro) return { erro: true };
|
||||||
|
return {
|
||||||
|
logradouro: jsonV.logradouro ?? "",
|
||||||
|
bairro: jsonV.bairro ?? "",
|
||||||
|
localidade: jsonV.localidade ?? "",
|
||||||
|
uf: jsonV.uf ?? "",
|
||||||
|
erro: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
susconecta/public/medico.jpg
Normal file
BIN
susconecta/public/medico.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 MiB |
@ -22,6 +22,6 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/api.js"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user