Compare commits

...

26 Commits

Author SHA1 Message Date
945c6eafb6 fix: Calendar and sidebar 2025-09-17 22:51:30 -03:00
9dfba10baa Merge branch 'develop' into feature/scheduling 2025-09-16 11:22:11 -03:00
f435ade0d6 Ajuste no .gitignore 2025-09-16 11:18:59 -03:00
9c7ce7d7d2 Finalizando merge da branch develop com origin/develop 2025-09-16 11:15:23 -03:00
70c67e4331 Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
Reviewed-on: #8
2025-09-14 21:55:48 +00:00
João Gustavo
ba64fdee8d add: new doctor page 2025-09-12 13:55:18 -03:00
a7c9c90ebb chore: update components config 2025-09-11 22:09:50 -03:00
a5d89b3fff Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
Reviewed-on: #7
2025-09-11 16:00:36 +00:00
0d416cacc9 resolvendo erro de imagens 2025-09-11 12:56:15 -03:00
e405cc5f89 WIP: alterações locais 2025-09-11 12:53:07 -03:00
bb4cc3895c Ajustes no .gitignore 2025-09-11 12:45:39 -03:00
953a4e7fa0 WIP: alterações locais 2025-09-11 12:42:54 -03:00
debc92dd45 chore(calendar): adjust naming for calendar component consistency 2025-09-11 01:49:36 -03:00
João Gustavo
ae637c480a fix/errors-medical-page 2025-09-11 00:53:39 -03:00
df530f7035 Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
Reviewed-on: #6
2025-09-11 03:13:39 +00:00
94839cca87 Adicionando calendario interativo do medico 2025-09-11 00:03:33 -03:00
93a4389f22 fix(merge): prefer feature versions (layout.tsx, package-lock.json) 2025-09-10 23:21:16 -03:00
f2db8663e1 fix(merge): resolve conflicts between develop and feature/patient-register 2025-09-10 23:18:01 -03:00
cdd44da18b chore: save changes before switching branch 2025-09-10 22:57:45 -03:00
b2a9ea047a feat(api): add and wire all mock endpoints
- Patients: list, get by id, create, update, delete
- Photo: upload, remove
- Attachments: list, add, remove
- Validations: validate CPF, lookup CEP
- Hook up env vars and shared fetch wrapper
2025-09-10 22:00:32 -03:00
a1ba4e5ee3 Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
Reviewed-on: #5
2025-09-10 16:31:12 +00:00
a44e9bcf81 Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register 2025-09-07 19:38:26 -03:00
372383fb42 feat: connect patient registration form to create patient API 2025-09-07 18:52:31 -03:00
3cce8a9774 fix: fix ref error in actions menu 2025-09-04 16:42:54 -03:00
91c84b6a46 fix: secure setting of onOpenChange on the patient form 2025-09-04 15:48:51 -03:00
8258fac83c feat: implement patient recorder 2025-09-03 21:13:13 -03:00
40 changed files with 3160 additions and 1744 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules
node_modules/

View File

@ -0,0 +1,41 @@
.fc-media-screen {
flex-grow: 1;
height: 74vh;
}
.fc-prev-button,
.fc-next-button {
background-color: var(--color-blue-600) !important;
border: none !important;
transition: 0.2s ease;
}
.fc-prev-button:hover,
.fc-next-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-timeGridWeek-button,
.fc-timeGridDay-button,
.fc-dayGridMonth-button {
border: none !important;
background-color: var(--color-blue-600) !important;
transition: 0.2s ease;
}
.fc-timeGridWeek-button:hover,
.fc-timeGridDay-button:hover,
.fc-dayGridMonth-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-button-active {
background-color: var(--color-blue-500) !important;
}
.fc-toolbar-title {
font-weight: bold;
color: var(--color-gray-900);
}

View File

@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import pt_br_locale from "@fullcalendar/core/locales/pt-br";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import { EventInput } from "@fullcalendar/core/index.js";
import { Sidebar } from "@/components/dashboard/sidebar";
import { PagesHeader } from "@/components/dashboard/header";
import { Button } from "@/components/ui/button";
import {
mockAppointments,
mockWaitingList,
} from "@/lib/mocks/appointment-mocks";
import "./index.css";
import Link from "next/link";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const ListaEspera = dynamic(
() => import("@/components/agendamento/ListaEspera"),
{ ssr: false }
);
export default function AgendamentoPage() {
const [appointments, setAppointments] = useState(mockAppointments);
const [waitingList, setWaitingList] = useState(mockWaitingList);
const [activeTab, setActiveTab] = useState<"calendar" | "espera">("calendar");
const [requestsList, setRequestsList] = useState<EventInput[]>();
useEffect(() => {
document.addEventListener("keydown", (event) => {
if (event.key === "c") {
setActiveTab("calendar");
}
if (event.key === "f") {
setActiveTab("espera");
}
});
}, []);
useEffect(() => {
let events: EventInput[] = [];
appointments.forEach((obj) => {
const event: EventInput = {
title: `${obj.patient}: ${obj.type}`,
start: new Date(obj.time),
end: new Date(new Date(obj.time).getTime() + obj.duration * 60 * 1000),
color:
obj.status === "confirmed"
? "#68d68a"
: obj.status === "pending"
? "#ffe55f"
: "#ff5f5fff",
};
events.push(event);
});
setRequestsList(events);
}, [appointments]);
// mantive para caso a lógica de salvar consulta passe a funcionar
const handleSaveAppointment = (appointment: any) => {
if (appointment.id) {
setAppointments((prev) =>
prev.map((a) => (a.id === appointment.id ? appointment : a))
);
} else {
const newAppointment = {
...appointment,
id: Date.now().toString(),
};
setAppointments((prev) => [...prev, newAppointment]);
}
};
const handleNotifyPatient = (patientId: string) => {
console.log(`Notificando paciente ${patientId}`);
};
return (
<div className="flex flex-row">
<div className="flex w-full flex-col">
<div className="flex w-full flex-col gap-10 p-6">
<div className="flex flex-row justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-foreground">{activeTab === "calendar" ? "Calendário" : "Lista de Espera"}</h1>
<p className="text-muted-foreground">
Navegue através dos atalhos: Calendário (C) ou Fila de espera
(F).
</p>
</div>
<div className="flex space-x-2">
{/* <Link href={"/agenda"}>
<Button className="bg-blue-600 hover:bg-blue-700">
Agenda
</Button>
</Link> */}
<DropdownMenu>
<DropdownMenuTrigger className="bg-blue-600 hover:bg-blue-700 px-5 py-1 text-white rounded-sm">
Opções &#187;
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href={"/agenda"}>
<DropdownMenuItem>Agendamento</DropdownMenuItem>
</Link>
<Link href={"/procedimento"}>
<DropdownMenuItem>Procedimento</DropdownMenuItem>
</Link>
<Link href={"/financeiro"}>
<DropdownMenuItem>Financeiro</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-row">
<Button
variant={"outline"}
className="bg-gray-100 hover:bg-gray-200 hover:text-foreground rounded-l-[100px] rounded-r-[0px]"
onClick={() => setActiveTab("calendar")}
>
Calendário
</Button>
<Button
variant={"outline"}
className="bg-gray-100 hover:bg-gray-200 hover:text-foreground rounded-r-[100px] rounded-l-[0px]"
onClick={() => setActiveTab("espera")}
>
Lista de espera
</Button>
</div>
</div>
</div>
{activeTab === "calendar" ? (
<div className="flex w-full">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={pt_br_locale}
events={requestsList}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
dateClick={(info) => {
info.view.calendar.changeView("timeGridDay", info.dateStr);
}}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
dayMaxEventRows={3}
/>
</div>
) : (
<ListaEspera
patients={waitingList}
onNotify={handleNotifyPatient}
onAddToWaitlist={() => {}}
/>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,251 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
import { Paciente, Endereco, listarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
import { PatientRegistrationForm } from "@/components/forms/patient-registration-form";
function normalizePaciente(p: any): Paciente {
const endereco: Endereco = {
cep: p.endereco?.cep ?? p.cep ?? "",
logradouro: p.endereco?.logradouro ?? p.logradouro ?? "",
numero: p.endereco?.numero ?? p.numero ?? "",
complemento: p.endereco?.complemento ?? p.complemento ?? "",
bairro: p.endereco?.bairro ?? p.bairro ?? "",
cidade: p.endereco?.cidade ?? p.cidade ?? "",
estado: p.endereco?.estado ?? p.estado ?? "",
};
return {
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
nome: p.nome ?? "",
nome_social: p.nome_social ?? null,
cpf: p.cpf ?? "",
rg: p.rg ?? null,
sexo: p.sexo ?? null,
data_nascimento: p.data_nascimento ?? null,
telefone: p.telefone ?? "",
email: p.email ?? "",
endereco,
observacoes: p.observacoes ?? null,
foto_url: p.foto_url ?? null,
};
}
export default function PacientesPage() {
const [patients, setPatients] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
async function loadAll() {
try {
setLoading(true);
const data = await listarPacientes({ page: 1, limit: 20 });
setPatients((data ?? []).map(normalizePaciente));
setError(null);
} catch (e: any) {
setPatients([]);
setError(e?.message || "Erro ao carregar pacientes.");
} finally {
setLoading(false);
}
}
useEffect(() => {
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);
}
function handleEdit(id: string) {
setEditingId(id);
setShowForm(true);
}
async function handleDelete(id: string) {
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.");
}
}
function handleSaved(p: Paciente) {
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);
}
async function handleBuscarServidor() {
const q = search.trim();
if (!q) return loadAll();
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;
}
await loadAll();
setTimeout(() => setSearch(q), 0);
}
if (loading) return <p>Carregando pacientes...</p>;
if (error) return <p className="text-red-500">{error}</p>;
if (showForm) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setShowForm(false)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingId ? "edit" : "create"}
patientId={editingId ? Number(editingId) : null}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
/>
</div>
);
}
return (
<div className="space-y-6 p-6">
{}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">Pacientes</h1>
<p className="text-muted-foreground">Gerencie os pacientes</p>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8 w-80"
placeholder="Buscar por nome, CPF ou ID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
/>
</div>
<Button variant="secondary" onClick={handleBuscarServidor}>Buscar</Button>
<Button onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" />
Novo paciente
</Button>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>CPF</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>Cidade</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-[100px]">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length > 0 ? (
filtered.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.nome || "(sem nome)"}</TableCell>
<TableCell>{p.cpf || "-"}</TableCell>
<TableCell>{p.telefone || "-"}</TableCell>
<TableCell>{p.endereco?.cidade || "-"}</TableCell>
<TableCell>{p.endereco?.estado || "-"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
<span className="sr-only">Abrir menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => alert(JSON.stringify(p, null, 2))}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Nenhum paciente encontrado
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
export default function DashboardPage() {
return (
<>
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">
Bem-vindo ao painel de controle
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Total de Pacientes
</h3>
<p className="text-2xl font-bold text-foreground">1,234</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Consultas Hoje
</h3>
<p className="text-2xl font-bold text-foreground">28</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Próximas Consultas
</h3>
<p className="text-2xl font-bold text-foreground">45</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Receita Mensal
</h3>
<p className="text-2xl font-bold text-foreground">R$ 45.230</p>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,22 @@
import type React from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { PagesHeader } from "@/components/dashboard/header";
export default function MainRoutesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-background flex">
<SidebarProvider>
<Sidebar />
<main className="flex-1">
<PagesHeader />
{children}
</main>
</SidebarProvider>
</div>
);
}

View File

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

View File

@ -1,139 +0,0 @@
// app/agendamento/page.tsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
// Importação dinâmica para evitar erros de SSR
const AgendaCalendar = dynamic(() => import('@/components/agendamento/AgendaCalendar'), {
ssr: false,
loading: () => (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-6"></div>
<div className="space-y-4">
<div className="h-12 bg-gray-200 rounded"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
)
});
const AppointmentModal = dynamic(() => import('@/components/agendamento/AppointmentModal'), { ssr: false });
const ListaEspera = dynamic(() => import('@/components/agendamento/ListaEspera'), { ssr: false });
// Dados mockados
const mockAppointments = [
{ id: '1', patient: 'Ana Costa', time: '2025-09-10T09:00', duration: 30, type: 'consulta' as const, status: 'confirmed' as const, professional: '1', notes: '' },
{ id: '2', patient: 'Pedro Alves', time: '2025-09-10T10:30', duration: 45, type: 'retorno' as const, status: 'pending' as const, professional: '2', notes: '' },
{ id: '3', patient: 'Mariana Lima', time: '2025-09-10T14:00', duration: 60, type: 'exame' as const, status: 'confirmed' as const, professional: '3', notes: '' },
];
const mockWaitingList = [
{ id: '1', name: 'Ana Costa', specialty: 'Cardiologia', preferredDate: '2025-09-12', priority: 'high' as const, contact: '(11) 99999-9999' },
{ id: '2', name: 'Pedro Alves', specialty: 'Dermatologia', preferredDate: '2025-09-15', priority: 'medium' as const, contact: '(11) 98888-8888' },
{ id: '3', name: 'Mariana Lima', specialty: 'Ortopedia', preferredDate: '2025-09-20', priority: 'low' as const, contact: '(11) 97777-7777' },
];
const mockProfessionals = [
{ id: '1', name: 'Dr. Carlos Silva', specialty: 'Cardiologia' },
{ id: '2', name: 'Dra. Maria Santos', specialty: 'Dermatologia' },
{ id: '3', name: 'Dr. João Oliveira', specialty: 'Ortopedia' },
];
export default function AgendamentoPage() {
const [appointments, setAppointments] = useState(mockAppointments);
const [waitingList, setWaitingList] = useState(mockWaitingList);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<any>(null);
const [activeTab, setActiveTab] = useState<'agenda' | 'espera'>('agenda');
const handleSaveAppointment = (appointment: any) => {
if (appointment.id) {
setAppointments(prev => prev.map(a => a.id === appointment.id ? appointment : a));
} else {
const newAppointment = {
...appointment,
id: Date.now().toString(),
};
setAppointments(prev => [...prev, newAppointment]);
}
};
const handleEditAppointment = (appointment: any) => {
setSelectedAppointment(appointment);
setIsModalOpen(true);
};
const handleAddAppointment = () => {
setSelectedAppointment(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedAppointment(null);
};
const handleNotifyPatient = (patientId: string) => {
console.log(`Notificando paciente ${patientId}`);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agendamento</h1>
<p className="text-gray-600 mt-2">Gerencie a agenda da clínica</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setActiveTab('agenda')}
className={`px-4 py-2 rounded-md ${
activeTab === 'agenda'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Agenda
</button>
<button
onClick={() => setActiveTab('espera')}
className={`px-4 py-2 rounded-md ${
activeTab === 'espera'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Lista de Espera
</button>
</div>
</div>
{activeTab === 'agenda' ? (
<AgendaCalendar
professionals={mockProfessionals}
appointments={appointments}
onAddAppointment={handleAddAppointment}
onEditAppointment={handleEditAppointment}
/>
) : (
<ListaEspera
patients={waitingList}
onNotify={handleNotifyPatient}
onAddToWaitlist={() => {}}
/>
)}
<AppointmentModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleSaveAppointment}
professionals={mockProfessionals}
appointment={selectedAppointment}
/>
</div>
);
}

View File

@ -1,19 +0,0 @@
import type React from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { DashboardHeader } from "@/components/dashboard/header"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-background flex">
<Sidebar />
<div className="flex-1 flex flex-col">
<DashboardHeader />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
)
}

View File

@ -1,381 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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 } from "lucide-react"
const patients = [
{
id: 1,
name: "Aaron Avalos Perez",
phone: "(75) 99982-6363",
city: "Aracaju",
state: "Sergipe",
lastAppointment: "26/09/2025 14:30",
nextAppointment: "19/08/2025 15:00",
isVip: false,
convenio: "unimed",
birthday: "1985-03-15",
age: 40,
},
{
id: 2,
name: "ABENANDO OLIVEIRA DE JESUS",
phone: "(75) 99986-0093",
city: "-",
state: "-",
lastAppointment: "Ainda não houve atendimento",
nextAppointment: "Nenhum atendimento agendado",
isVip: false,
convenio: "particular",
birthday: "1978-12-03",
age: 46,
},
{
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() {
const [searchTerm, setSearchTerm] = useState("")
const [selectedConvenio, setSelectedConvenio] = useState("all") // Updated default value to "all"
const [showVipOnly, setShowVipOnly] = useState(false)
const [showBirthdays, setShowBirthdays] = useState(false)
const [advancedFilters, setAdvancedFilters] = useState({
city: "",
state: "",
minAge: "",
maxAge: "",
lastAppointmentFrom: "",
lastAppointmentTo: "",
})
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false)
const filteredPatients = patients.filter((patient) => {
const matchesSearch =
patient.name.toLowerCase().includes(searchTerm.toLowerCase()) || patient.phone.includes(searchTerm)
const matchesConvenio = selectedConvenio === "all" || patient.convenio === selectedConvenio
const matchesVip = !showVipOnly || patient.isVip
// Check if patient has birthday this month
const currentMonth = new Date().getMonth() + 1
const patientBirthMonth = new Date(patient.birthday).getMonth() + 1
const matchesBirthday = !showBirthdays || patientBirthMonth === currentMonth
// Advanced filters
const matchesCity = !advancedFilters.city || patient.city.toLowerCase().includes(advancedFilters.city.toLowerCase())
const matchesState =
!advancedFilters.state || patient.state.toLowerCase().includes(advancedFilters.state.toLowerCase())
const matchesMinAge = !advancedFilters.minAge || patient.age >= Number.parseInt(advancedFilters.minAge)
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) => {
console.log("[v0] Ver detalhes do paciente:", patientId)
// TODO: Navigate to patient details page
}
const handleEditPatient = (patientId: number) => {
console.log("[v0] Editar paciente:", patientId)
// TODO: Navigate to edit patient form
}
const handleScheduleAppointment = (patientId: number) => {
console.log("[v0] Marcar consulta para paciente:", patientId)
// TODO: Open appointment scheduling modal
}
const handleDeletePatient = (patientId: number) => {
console.log("[v0] Excluir paciente:", patientId)
// TODO: Show confirmation dialog and delete patient
}
return (
<div 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 as informações de seus pacientes</p>
</div>
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" />
Adicionar
</Button>
</div>
{/* Filters */}
<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>
<Select value={selectedConvenio} onValueChange={setSelectedConvenio}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Selecione o Convênio" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os Convênios</SelectItem>
<SelectItem value="unimed">Unimed</SelectItem>
<SelectItem value="bradesco">Bradesco Saúde</SelectItem>
<SelectItem value="amil">Amil</SelectItem>
<SelectItem value="particular">Particular</SelectItem>
</SelectContent>
</Select>
<Button
variant={showVipOnly ? "default" : "outline"}
onClick={() => setShowVipOnly(!showVipOnly)}
className="flex items-center gap-2"
>
<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>
{/* Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>Cidade</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Último atendimento</TableHead>
<TableHead>Próximo atendimento</TableHead>
<TableHead className="w-[100px]">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPatients.map((patient) => (
<TableRow key={patient.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
<span className="text-xs font-medium">{patient.name.charAt(0).toUpperCase()}</span>
</div>
<button onClick={() => handleViewDetails(patient.id)} className="hover:text-primary cursor-pointer">
{patient.name}
</button>
{patient.isVip && (
<Badge variant="secondary" className="text-xs">
VIP
</Badge>
)}
</div>
</TableCell>
<TableCell>{patient.phone}</TableCell>
<TableCell>{patient.city}</TableCell>
<TableCell>{patient.state}</TableCell>
<TableCell>
<span
className={patient.lastAppointment === "Ainda não houve atendimento" ? "text-muted-foreground" : ""}
>
{patient.lastAppointment}
</span>
</TableCell>
<TableCell>
<span
className={patient.nextAppointment === "Nenhum atendimento agendado" ? "text-muted-foreground" : ""}
>
{patient.nextAppointment}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<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>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-sm text-muted-foreground">
Mostrando {filteredPatients.length} de {patients.length} pacientes
</div>
</div>
)
}

View File

@ -1,29 +0,0 @@
export default function DashboardPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">Bem-vindo ao painel de controle</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">Total de Pacientes</h3>
<p className="text-2xl font-bold text-foreground">1,234</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">Consultas Hoje</h3>
<p className="text-2xl font-bold text-foreground">28</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">Próximas Consultas</h3>
<p className="text-2xl font-bold text-foreground">45</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">Receita Mensal</h3>
<p className="text-2xl font-bold text-foreground">R$ 45.230</p>
</div>
</div>
</div>
)
}

View File

@ -10,7 +10,7 @@
--card-foreground: #334155;
--popover: #ffffff;
--popover-foreground: #475569;
--primary: #0f766e;
--primary: var(--color-blue-500);
--primary-foreground: #ffffff;
--secondary: #e2e8f0;
--secondary-foreground: #475569;
@ -22,7 +22,7 @@
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #f1f5f9;
--ring: #0f766e;
--ring: var(--color-blue-500);
--chart-1: #0891b2;
--chart-2: #0f766e;
--chart-3: #f59e0b;
@ -31,47 +31,12 @@
--radius: 0.5rem;
--sidebar: #ffffff;
--sidebar-foreground: #475569;
--sidebar-primary: #0f766e;
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #0891b2;
--sidebar-accent: var(--color-blue-500);
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #e2e8f0;
--sidebar-ring: #0f766e;
}
.dark {
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--popover: #1e293b;
--popover-foreground: #f1f5f9;
--primary: #14b8a6;
--primary-foreground: #0f172a;
--secondary: #334155;
--secondary-foreground: #f1f5f9;
--muted: #334155;
--muted-foreground: #94a3b8;
--accent: #0891b2;
--accent-foreground: #f1f5f9;
--destructive: #ef4444;
--destructive-foreground: #f1f5f9;
--border: #334155;
--input: #334155;
--ring: #14b8a6;
--chart-1: #0891b2;
--chart-2: #14b8a6;
--chart-3: #f59e0b;
--chart-4: #ef4444;
--chart-5: #94a3b8;
--sidebar: #1e293b;
--sidebar-foreground: #f1f5f9;
--sidebar-primary: #14b8a6;
--sidebar-primary-foreground: #0f172a;
--sidebar-accent: #0891b2;
--sidebar-accent-foreground: #f1f5f9;
--sidebar-border: #334155;
--sidebar-ring: #14b8a6;
--sidebar-ring: var(--color-blue-500);
}
@theme inline {

View File

@ -1,26 +1,13 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
const geistSans = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-geist-sans",
})
const geistMono = Geist_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-geist-mono",
})
export const metadata: Metadata = {
title: "SUSConecta - Conectando Pacientes e Profissionais de Saúde",
description:
"Plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
generator: 'v0.app'
generator: 'v0.app'
}
export default function RootLayout({
@ -29,8 +16,8 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="pt-BR" className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body>{children}</body>
<html lang="pt-BR" className="antialiased">
<body style={{ fontFamily: "var(--font-geist-sans)" }}>{children}</body>
</html>
)
}

View File

@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronDown, RotateCcw } from "lucide-react";
import { Plus } from "lucide-react";
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
import FooterAgenda from "@/components/agenda/FooterAgenda";
export default function ProcedimentoPage() {
const pathname = usePathname();
const router = useRouter();
const [bloqueio, setBloqueio] = useState(false);
const isAg = pathname?.startsWith("/agendamento");
const isPr = pathname?.startsWith("/procedimento");
const isFi = pathname?.startsWith("/financeiro");
const tab = (active: boolean, extra = "") =>
`px-4 py-1.5 text-[13px] border ${
active
? "border-sky-500 bg-sky-50 text-sky-700 font-medium"
: "text-gray-700 hover:bg-gray-100"
} ${extra}`;
return (
<div className="w-full min-h-screen flex flex-col bg-white">
{/* HEADER */}
<HeaderAgenda />
{/* CORPO */}
<main className="mx-auto w-full max-w-7xl px-8 py-6 space-y-6 flex-grow">
{/* ATENDIMENTOS */}
<section className="space-y-6">
{/* Selo Atendimento com + dentro da bolinha */}
<div className="inline-flex items-center gap-2 border px-3 py-1.5 bg-white text-[12px] rounded-md cursor-pointer hover:bg-gray-50">
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-gray-400 bg-gray-100 text-gray-700">
<Plus className="h-3 w-3" strokeWidth={2} />
</span>
Atendimento
</div>
{/* Traço separador */}
<div className="border-b border-gray-200" />
{/* PROCEDIMENTOS */}
<div className="space-y-1">
<Label className="text-[13px] text-foreground/80">
Procedimentos
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar"
className="h-10 w-full rounded-md pl-8 pr-8 border border-gray-300 focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
{/* Traço separador */}
<div className="border-b border-gray-200" />
{/* OUTRAS DESPESAS */}
<div className="space-y-1">
<Label className="text-[13px] text-foreground/80">
Outras despesas
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar"
className="h-10 w-full rounded-md pl-8 pr-8 border border-gray-300 focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
</section>
</main>
{/* RODAPÉ FIXO */}
<FooterAgenda />
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -8,18 +8,18 @@ export function AboutSection() {
<section className="py-16 lg:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
{}
<div className="space-y-8">
{/* Professional Image */}
{}
<div className="relative">
<img
src="/professional-working-on-laptop-in-modern-office-en.jpg"
alt="Profissional trabalhando em laptop em ambiente moderno"
src="/Screenshot 2025-09-11 121911.png"
alt="Profissional trabalhando em laptop"
className="w-full h-auto rounded-2xl"
/>
</div>
{/* Objective Card */}
{}
<Card className="bg-primary text-primary-foreground p-8 rounded-2xl">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 w-12 h-12 bg-primary-foreground/20 rounded-full flex items-center justify-center">
@ -36,7 +36,7 @@ export function AboutSection() {
</Card>
</div>
{/* Right Content */}
{}
<div className="space-y-8">
<div className="space-y-4">
<div className="inline-block px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium uppercase tracking-wide">

View File

@ -0,0 +1,34 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { useState } from "react";
import Link from "next/link";
export default function FooterAgenda() {
const [bloqueio, setBloqueio] = useState(false);
return (
<div className="sticky bottom-0 left-0 right-0 border-t bg-white">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={bloqueio} onCheckedChange={setBloqueio} />
<Label className="text-sm">Bloqueio de Agenda</Label>
</div>
<div className="flex gap-2">
<Link href={"/calendar"}>
<Button variant="ghost" className="hover:bg-blue-100 hover:text-foreground">Cancelar</Button>
</Link>
<Link href={"/calendar"}>
<Button>
<Save className="mr-2 h-4 w-4" />
Salvar as alterações
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import { RotateCcw } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
export default function HeaderAgenda() {
const pathname = usePathname();
const router = useRouter();
const isAg = pathname?.startsWith("/agendamento");
const isPr = pathname?.startsWith("/procedimento");
const isFi = pathname?.startsWith("/financeiro");
const tabCls = (active: boolean, extra = "") =>
`px-4 py-1.5 text-[13px] border ${
active
? "border-blue-500 bg-blue-50 text-blue-700 font-medium"
: "text-gray-700 hover:bg-gray-100"
} ${extra}`;
return (
<header className="border-b bg-white">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<h1 className="text-[18px] font-semibold">Novo Agendamento</h1>
<div className="flex items-center gap-2">
<nav
role="tablist"
aria-label="Navegação de Agendamento"
className="flex items-center gap-2"
>
<Link
href="/agenda"
role="tab"
aria-selected={isAg}
className={tabCls(Boolean(isAg)) + " rounded-md"}
>
Agendamento
</Link>
<Link
href="/procedimento"
role="tab"
aria-selected={isPr}
className={tabCls(Boolean(isPr)) + " rounded-md"}
>
Procedimento
</Link>
<Link
href="/financeiro"
role="tab"
aria-selected={isFi}
className={tabCls(Boolean(isFi)) + " rounded-md"}
>
Financeiro
</Link>
</nav>
<button
type="button"
aria-label="Histórico"
onClick={() => router.back()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border bg-white text-gray-700 hover:bg-gray-100"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
</header>
);
}

View File

@ -1,119 +0,0 @@
// app/agenda/page.tsx
'use client';
import { useState } from 'react';
import { AgendaCalendar, AppointmentModal, ListaEspera } from '@/components/agendamento';
// Dados mockados - substitua pelos seus dados reais
const mockAppointments = [
{ id: '1', patient: 'Ana Costa', time: '2025-09-10T09:00', duration: 30, type: 'consulta' as const, status: 'confirmed' as const, professional: '1', notes: '' },
{ id: '2', patient: 'Pedro Alves', time: '2025-09-10T10:30', duration: 45, type: 'retorno' as const, status: 'pending' as const, professional: '2', notes: '' },
{ id: '3', patient: 'Mariana Lima', time: '2025-09-10T14:00', duration: 60, type: 'exame' as const, status: 'confirmed' as const, professional: '3', notes: '' },
];
const mockWaitingList = [
{ id: '1', name: 'Ana Costa', specialty: 'Cardiologia', preferredDate: '2025-09-12', priority: 'high' as const, contact: '(11) 99999-9999' },
{ id: '2', name: 'Pedro Alves', specialty: 'Dermatologia', preferredDate: '2025-09-15', priority: 'medium' as const, contact: '(11) 98888-8888' },
{ id: '3', name: 'Mariana Lima', specialty: 'Ortopedia', preferredDate: '2025-09-20', priority: 'low' as const, contact: '(11) 97777-7777' },
];
const mockProfessionals = [
{ id: '1', name: 'Dr. Carlos Silva', specialty: 'Cardiologia' },
{ id: '2', name: 'Dra. Maria Santos', specialty: 'Dermatologia' },
{ id: '3', name: 'Dr. João Oliveira', specialty: 'Ortopedia' },
];
export default function AgendaPage() {
const [appointments, setAppointments] = useState(mockAppointments);
const [waitingList, setWaitingList] = useState(mockWaitingList);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<any>(null);
const [activeTab, setActiveTab] = useState<'agenda' | 'espera'>('agenda');
const handleSaveAppointment = (appointment: any) => {
if (appointment.id) {
// Editar agendamento existente
setAppointments(prev => prev.map(a => a.id === appointment.id ? appointment : a));
} else {
// Novo agendamento
const newAppointment = {
...appointment,
id: Date.now().toString(),
};
setAppointments(prev => [...prev, newAppointment]);
}
};
const handleEditAppointment = (appointment: any) => {
setSelectedAppointment(appointment);
setIsModalOpen(true);
};
const handleAddAppointment = () => {
setSelectedAppointment(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedAppointment(null);
};
const handleNotifyPatient = (patientId: string) => {
// Lógica para notificar paciente
console.log(`Notificando paciente ${patientId}`);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">Agendamento</h1>
<div className="flex space-x-2">
<button
onClick={() => setActiveTab('agenda')}
className={`px-4 py-2 rounded-md ${
activeTab === 'agenda'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Agenda
</button>
<button
onClick={() => setActiveTab('espera')}
className={`px-4 py-2 rounded-md ${
activeTab === 'espera'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Lista de Espera
</button>
</div>
</div>
{activeTab === 'agenda' ? (
<AgendaCalendar
professionals={mockProfessionals}
appointments={appointments}
onAddAppointment={handleAddAppointment}
onEditAppointment={handleEditAppointment}
/>
) : (
<ListaEspera
patients={waitingList}
onNotify={handleNotifyPatient}
onAddToWaitlist={() => {/* implementar */}}
/>
)}
<AppointmentModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleSaveAppointment}
professionals={mockProfessionals}
appointment={selectedAppointment}
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
// components/agendamento/AgendaCalendar.tsx (atualizado)
'use client';
import { useState } from 'react';
@ -86,7 +86,7 @@ export default function AgendaCalendar({
setCurrentDate(new Date());
};
// Filtra os agendamentos por profissional selecionado
const filteredAppointments = selectedProfessional === 'all'
? appointments
: appointments.filter(app => app.professional === selectedProfessional);
@ -187,7 +187,7 @@ export default function AgendaCalendar({
</div>
</div>
{/* Visualização de Dia/Semana (calendário) */}
{}
{view !== 'month' && (
<div className="overflow-auto">
<div className="min-w-full">
@ -256,7 +256,7 @@ export default function AgendaCalendar({
</div>
)}
{/* Visualização de Mês (lista) */}
{}
{view === 'month' && (
<div className="p-4">
<div className="space-y-4">

View File

@ -1,4 +1,4 @@
// components/agendamento/ListaEspera.tsx
'use client';
import { useState } from 'react';

View File

@ -12,12 +12,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "../ui/sidebar"
export function DashboardHeader() {
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
return (
<header className="h-16 border-b border-border bg-background px-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<h1 className="text-lg font-semibold text-foreground">NÚCLEO DE ESPECIALIDADES</h1>
<div className="flex flex-row items-center gap-4">
<SidebarTrigger />
<div className="flex items-start flex-col justify-center py-2">
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
<p className="text-gray-600">{subtitle}</p>
</div>
</div>
<div className="flex items-center space-x-4">

View File

@ -3,12 +3,37 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Home, Calendar, Users, UserCheck, FileText, BarChart3, Settings, Stethoscope } from "lucide-react"
import {
Sidebar as ShadSidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarRail,
} from "@/components/ui/sidebar"
import {
Home,
Calendar,
Users,
UserCheck,
FileText,
BarChart3,
Settings,
Stethoscope,
User,
} from "lucide-react"
const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: Home },
{ name: "Agendamento", href: "/agendamento", icon: Calendar },
{ name: "Calendario", href: "/calendar", icon: Calendar },
{ name: "Pacientes", href: "/dashboard/pacientes", icon: Users },
{ name: "Médicos", href: "/dashboard/medicos", icon: User },
{ name: "Consultas", href: "/dashboard/consultas", icon: UserCheck },
{ name: "Prontuários", href: "/dashboard/prontuarios", icon: FileText },
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
@ -19,36 +44,61 @@ export function Sidebar() {
const pathname = usePathname()
return (
<div className="w-64 bg-sidebar border-r border-sidebar-border">
<div className="p-6">
<Link href="/" className="flex items-center space-x-2 hover:opacity-80 transition-opacity">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<ShadSidebar
/* mude para side="right" se preferir */
side="left"
/* isso faz colapsar para ícones */
collapsible="icon"
className="border-r border-sidebar-border"
>
<SidebarHeader>
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity pt-2"
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0">
<Stethoscope className="w-4 h-4 text-primary-foreground" />
</div>
<span className="text-lg font-semibold text-sidebar-foreground">SUSConecta</span>
</Link>
</div>
<nav className="px-3 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground",
)}
>
<item.icon className="mr-3 h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
</div>
{/* este span some no modo ícone */}
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
SUSConecta
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => {
const isActive = pathname === item.href
return (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} className="flex items-center">
<item.icon className="mr-3 h-4 w-4 shrink-0" />
{/* o texto esconde quando colapsa */}
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* espaço para perfil/logout, se quiser */}</SidebarFooter>
{/* rail clicável/hover que ajuda a reabrir/fechar */}
<SidebarRail />
</ShadSidebar>
)
}

View File

@ -12,10 +12,10 @@ export function Footer() {
<footer className="bg-background border-t border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
{/* Copyright */}
{}
<div className="text-muted-foreground text-sm">© 2025 SUS Conecta</div>
{/* Footer Links */}
{}
<nav className="flex items-center space-x-8">
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Termos
@ -28,7 +28,7 @@ export function Footer() {
</a>
</nav>
{/* Back to Top Button */}
{}
<Button
variant="outline"
size="sm"

View File

@ -0,0 +1,616 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2 } from "lucide-react";
import {
Paciente,
PacienteInput,
buscarCepAPI,
validarCPF,
criarPaciente,
atualizarPaciente,
uploadFotoPaciente,
removerFotoPaciente,
adicionarAnexo,
listarAnexos,
removerAnexo,
buscarPacientePorId,
} from "@/lib/api";
type Mode = "create" | "edit";
export interface PatientRegistrationFormProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
patientId?: number | null;
inline?: boolean;
mode?: Mode;
onSaved?: (paciente: Paciente) => void;
onClose?: () => void;
}
type FormData = {
photo: File | null;
nome: string;
nome_social: string;
cpf: string;
rg: string;
sexo: string;
data_nascimento: string;
email: string;
telefone: string;
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
estado: string;
observacoes: string;
anexos: File[];
};
const initial: FormData = {
photo: null,
nome: "",
nome_social: "",
cpf: "",
rg: "",
sexo: "",
data_nascimento: "",
email: "",
telefone: "",
cep: "",
logradouro: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
observacoes: "",
anexos: [],
};
export function PatientRegistrationForm({
open = true,
onOpenChange,
patientId = null,
inline = false,
mode = "create",
onSaved,
onClose,
}: PatientRegistrationFormProps) {
const [form, setForm] = useState<FormData>(initial);
const [errors, setErrors] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false });
const [isSubmitting, setSubmitting] = useState(false);
const [isSearchingCEP, setSearchingCEP] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
const title = useMemo(() => (mode === "create" ? "Cadastro de Paciente" : "Editar Paciente"), [mode]);
useEffect(() => {
async function load() {
if (mode !== "edit" || patientId == null) return;
try {
const p = await buscarPacientePorId(String(patientId));
setForm((s) => ({
...s,
nome: p.nome || "",
nome_social: p.nome_social || "",
cpf: p.cpf || "",
rg: p.rg || "",
sexo: p.sexo || "",
data_nascimento: (p.data_nascimento as string) || "",
telefone: p.telefone || "",
email: p.email || "",
cep: p.endereco?.cep || "",
logradouro: p.endereco?.logradouro || "",
numero: p.endereco?.numero || "",
complemento: p.endereco?.complemento || "",
bairro: p.endereco?.bairro || "",
cidade: p.endereco?.cidade || "",
estado: p.endereco?.estado || "",
observacoes: p.observacoes || "",
}));
const ax = await listarAnexos(String(patientId)).catch(() => []);
setServerAnexos(Array.isArray(ax) ? ax : []);
} catch {
}
}
load();
}, [mode, patientId]);
function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
setForm((s) => ({ ...s, [k]: v }));
if (errors[k as string]) setErrors((e) => ({ ...e, [k]: "" }));
}
function formatCPF(v: string) {
const n = v.replace(/\D/g, "").slice(0, 11);
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
}
function handleCPFChange(v: string) {
setField("cpf", formatCPF(v));
}
function formatCEP(v: string) {
const n = v.replace(/\D/g, "").slice(0, 8);
return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`);
}
async function fillFromCEP(cep: string) {
const clean = cep.replace(/\D/g, "");
if (clean.length !== 8) return;
setSearchingCEP(true);
try {
const res = await buscarCepAPI(clean);
if (res?.erro) {
setErrors((e) => ({ ...e, cep: "CEP não encontrado" }));
} else {
setField("logradouro", res.logradouro ?? "");
setField("bairro", res.bairro ?? "");
setField("cidade", res.localidade ?? "");
setField("estado", res.uf ?? "");
}
} catch {
setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" }));
} finally {
setSearchingCEP(false);
}
}
function validateLocal(): boolean {
const e: Record<string, string> = {};
if (!form.nome.trim()) e.nome = "Nome é obrigatório";
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
setErrors(e);
return Object.keys(e).length === 0;
}
function toPayload(): PacienteInput {
return {
nome: form.nome,
nome_social: form.nome_social || null,
cpf: form.cpf,
rg: form.rg || null,
sexo: form.sexo || null,
data_nascimento: form.data_nascimento || null,
telefone: form.telefone || null,
email: form.email || null,
endereco: {
cep: form.cep || null,
logradouro: form.logradouro || null,
numero: form.numero || null,
complemento: form.complemento || null,
bairro: form.bairro || null,
cidade: form.cidade || null,
estado: form.estado || null,
},
observacoes: form.observacoes || null,
};
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validateLocal()) return;
try {
const { valido, existe } = await validarCPF(form.cpf);
if (!valido) {
setErrors((e) => ({ ...e, cpf: "CPF inválido (validação externa)" }));
return;
}
if (existe && mode === "create") {
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
return;
}
} catch {
}
setSubmitting(true);
try {
const payload = toPayload();
let saved: Paciente;
if (mode === "create") {
saved = await criarPaciente(payload);
} else {
if (patientId == null) throw new Error("Paciente inexistente para edição");
saved = await atualizarPaciente(String(patientId), payload);
}
if (form.photo && saved?.id) {
try {
await uploadFotoPaciente(saved.id, form.photo);
} catch {}
}
if (form.anexos.length && saved?.id) {
for (const f of form.anexos) {
try {
await adicionarAnexo(saved.id, f);
} catch {}
}
}
onSaved?.(saved);
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
if (inline) onClose?.();
else onOpenChange?.(false);
alert(mode === "create" ? "Paciente cadastrado!" : "Paciente atualizado!");
} catch (err: any) {
setErrors({ submit: err?.message || "Erro ao salvar paciente." });
} finally {
setSubmitting(false);
}
}
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) {
setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." }));
return;
}
setField("photo", f);
const fr = new FileReader();
fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || ""));
fr.readAsDataURL(f);
}
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) {
const fs = Array.from(e.target.files || []);
setField("anexos", [...form.anexos, ...fs]);
}
function removeLocalAnexo(idx: number) {
const clone = [...form.anexos];
clone.splice(idx, 1);
setField("anexos", clone);
}
async function handleRemoverFotoServidor() {
if (mode !== "edit" || !patientId) return;
try {
await removerFotoPaciente(String(patientId));
alert("Foto removida.");
} catch (e: any) {
alert(e?.message || "Não foi possível remover a foto.");
}
}
async function handleRemoverAnexoServidor(anexoId: string | number) {
if (mode !== "edit" || !patientId) return;
try {
await removerAnexo(String(patientId), anexoId);
setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId)));
} catch (e: any) {
alert(e?.message || "Não foi possível remover o anexo.");
}
}
const content = (
<>
{errors.submit && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errors.submit}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{}
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<User className="h-4 w-4" />
Dados Pessoais
</span>
{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
{photoPreview ? (
<img src={photoPreview} alt="Preview" className="w-full h-full object-cover" />
) : (
<FileImage className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="photo" className="cursor-pointer">
<Button type="button" variant="outline" asChild>
<span>
<Upload className="mr-2 h-4 w-4" /> Carregar Foto
</span>
</Button>
</Label>
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
{mode === "edit" && (
<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}>
<Trash2 className="mr-2 h-4 w-4" /> Remover foto
</Button>
)}
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />
{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}
</div>
<div className="space-y-2">
<Label>Nome Social</Label>
<Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>CPF *</Label>
<Input
value={form.cpf}
onChange={(e) => handleCPFChange(e.target.value)}
placeholder="000.000.000-00"
maxLength={14}
className={errors.cpf ? "border-destructive" : ""}
/>
{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}
</div>
<div className="space-y-2">
<Label>RG</Label>
<Input value={form.rg} onChange={(e) => setField("rg", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Sexo</Label>
<RadioGroup value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="masculino" id="masculino" />
<Label htmlFor="masculino">Masculino</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="feminino" id="feminino" />
<Label htmlFor="feminino">Feminino</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="outro" id="outro" />
<Label htmlFor="outro">Outro</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label>Data de Nascimento</Label>
<Input type="date" value={form.data_nascimento} onChange={(e) => setField("data_nascimento", e.target.value)} />
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{}
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between">
<span>Contato</span>
{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>E-mail</Label>
<Input value={form.email} onChange={(e) => setField("email", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Telefone</Label>
<Input value={form.telefone} onChange={(e) => setField("telefone", e.target.value)} />
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{}
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between">
<span>Endereço</span>
{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>CEP</Label>
<div className="relative">
<Input
value={form.cep}
onChange={(e) => {
const v = formatCEP(e.target.value);
setField("cep", v);
if (v.replace(/\D/g, "").length === 8) fillFromCEP(v);
}}
placeholder="00000-000"
maxLength={9}
disabled={isSearchingCEP}
className={errors.cep ? "border-destructive" : ""}
/>
{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}
</div>
{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}
</div>
<div className="space-y-2">
<Label>Logradouro</Label>
<Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Número</Label>
<Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Complemento</Label>
<Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Bairro</Label>
<Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Cidade</Label>
<Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Estado</Label>
<Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" />
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{}
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between">
<span>Observações e Anexos</span>
{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Observações</Label>
<Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Adicionar anexos</Label>
<div className="border-2 border-dashed rounded-lg p-4">
<Label htmlFor="anexos" className="cursor-pointer">
<div className="text-center">
<Upload className="mx-auto h-7 w-7 mb-2" />
<p className="text-sm text-muted-foreground">Clique para adicionar documentos (PDF, imagens, etc.)</p>
</div>
</Label>
<Input id="anexos" type="file" multiple className="hidden" onChange={addLocalAnexos} />
</div>
{form.anexos.length > 0 && (
<div className="space-y-2">
{form.anexos.map((f, i) => (
<div key={`${f.name}-${i}`} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{f.name}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => removeLocalAnexo(i)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
{mode === "edit" && serverAnexos.length > 0 && (
<div className="space-y-2">
<Label>Anexos enviados</Label>
<div className="space-y-2">
{serverAnexos.map((ax) => {
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
return (
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{}
<div className="flex justify-end gap-4 pt-6 border-t">
<Button type="button" variant="outline" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}>
<XCircle className="mr-2 h-4 w-4" />
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}
</Button>
</div>
</form>
</>
);
if (inline) return <div className="space-y-6">{content}</div>;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<User className="h-5 w-5" /> {title}
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}

View File

@ -1,12 +1,14 @@
"use client"
"use client";
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Menu, X } from "lucide-react"
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pathname = usePathname();
return (
<header className="bg-background border-b border-border sticky top-0 z-50">
@ -19,20 +21,27 @@ export function Header() {
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-8">
{}
<nav className="hidden md:flex items-center gap-10">
<Link
href="/"
className="text-foreground hover:text-primary transition-colors border-b-2 border-primary pb-1"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/" ? "border-b-blue-500" : ""
}`}
>
Início
</Link>
<Link href="/sobre" className="text-muted-foreground hover:text-primary transition-colors">
<Link
href="/sobre"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/sobre" ? "border-b-blue-500" : ""
}`}
>
Sobre
</Link>
</nav>
{/* Desktop Action Buttons */}
{}
<div className="hidden md:flex items-center space-x-3">
<Button
variant="outline"
@ -40,11 +49,9 @@ export function Header() {
>
Sou Paciente
</Button>
<Link href="/dashboard">
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground">
Sou Profissional de Saúde
</Button>
</Link>
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground">
<Link href="/profissional">Sou Profissional de Saúde</Link>
</Button>
<Link href="/dashboard">
<Button
variant="outline"
@ -55,13 +62,17 @@ export function Header() {
</Link>
</div>
{/* Mobile Menu Button */}
<button className="md:hidden p-2" onClick={() => setIsMenuOpen(!isMenuOpen)} aria-label="Toggle menu">
{}
<button
className="md:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="Toggle menu"
>
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* Mobile Menu */}
{}
{isMenuOpen && (
<div className="md:hidden py-4 border-t border-border">
<nav className="flex flex-col space-y-4">
@ -82,16 +93,14 @@ export function Header() {
<div className="flex flex-col space-y-2 pt-4">
<Button
variant="outline"
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent w-full"
className="text-primary border-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
>
Sou Paciente
</Button>
<Link href="/dashboard" onClick={() => setIsMenuOpen(false)}>
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
Sou Profissional de Saúde
</Button>
</Link>
<Link href="/dashboard" onClick={() => setIsMenuOpen(false)}>
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
Sou Profissional de Saúde
</Button>
<Link href="/dashboard">
<Button
variant="outline"
className="text-slate-700 border-slate-600 hover:bg-slate-700 hover:text-white bg-transparent w-full"
@ -105,6 +114,5 @@ export function Header() {
)}
</div>
</header>
)
);
}

View File

@ -7,7 +7,7 @@ export function HeroSection() {
<section className="py-16 lg:py-24 bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Content */}
{}
<div className="space-y-8">
<div className="space-y-4">
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
@ -23,7 +23,7 @@ export function HeroSection() {
</div>
</div>
{/* Action Buttons */}
{}
<div className="flex flex-col sm:flex-row gap-4">
<Button size="lg" className="bg-primary hover:bg-primary/90 text-primary-foreground">
Sou Paciente
@ -38,19 +38,19 @@ export function HeroSection() {
</div>
</div>
{/* Hero Image */}
{}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-8">
<img
src="/professional-doctor-in-white-coat-smiling-confiden.png"
alt="Médico profissional sorrindo em ambiente médico moderno"
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
alt="Médico profissional sorrindo"
className="w-full h-auto rounded-lg"
/>
</div>
</div>
</div>
{/* Features */}
{}
<div className="mt-16 grid md:grid-cols-3 gap-8">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">

View File

@ -1,4 +1,4 @@
// hooks/useAgenda.ts
import { useState } from 'react';
export interface Appointment {
@ -42,10 +42,10 @@ export const useAgenda = () => {
const handleSaveAppointment = (appointment: Appointment) => {
if (appointment.id) {
// Editar agendamento existente
setAppointments(prev => prev.map(a => a.id === appointment.id ? appointment : a));
} else {
// Novo agendamento
const newAppointment = {
...appointment,
id: Date.now().toString(),
@ -70,7 +70,7 @@ export const useAgenda = () => {
};
const handleNotifyPatient = (patientId: string) => {
// Lógica para notificar paciente
console.log(`Notificando paciente ${patientId}`);
};

View File

@ -1,6 +1,6 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
@ -93,8 +93,7 @@ export const reducer = (state: State, action: Action): State => {
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {

252
susconecta/lib/api.ts Normal file
View File

@ -0,0 +1,252 @@
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;
};
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 {
}
if (!res.ok) {
const code = json?.apidogError?.code ?? res.status;
const msg = json?.apidogError?.message ?? res.statusText;
throw new Error(`${code}: ${msg}`);
}
return (json?.data ?? json) as T;
}
//
// Pacientes (CRUD)
//
export async function listarPacientes(params?: { page?: number; limit?: number; q?: string }): Promise<Paciente[]> {
const query = new URLSearchParams();
if (params?.page) query.set("page", String(params.page));
if (params?.limit) query.set("limit", String(params.limit));
if (params?.q) query.set("q", params.q);
const url = `${API_BASE}${PATHS.pacientes}${query.toString() ? `?${query.toString()}` : ""}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<Paciente[]>>(res);
logAPI("listarPacientes", { url, result: data });
return data?.data ?? (data as any);
}
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("buscarPacientePorId", { url, result: data });
return data?.data ?? (data as any);
}
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacientes}`;
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(input) });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("criarPaciente", { url, payload: input, result: data });
return data?.data ?? (data as any);
}
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "PUT", headers: headers("json"), body: JSON.stringify(input) });
const data = await parse<ApiOk<Paciente>>(res);
logAPI("atualizarPaciente", { url, payload: input, result: data });
return data?.data ?? (data as any);
}
export async function excluirPaciente(id: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.pacienteId(id)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("excluirPaciente", { url, result: { ok: true } });
}
//
// Foto
//
export async function uploadFotoPaciente(id: string | number, file: File): Promise<{ foto_url?: string; thumbnail_url?: string }> {
const url = `${API_BASE}${PATHS.foto(id)}`;
const fd = new FormData();
// nome de campo mais comum no mock
fd.append("foto", file);
const res = await fetch(url, { method: "POST", headers: headers("form"), body: fd });
const data = await parse<ApiOk<{ foto_url?: string; thumbnail_url?: string }>>(res);
logAPI("uploadFotoPaciente", { url, payload: { file: file.name }, result: data });
return data?.data ?? (data as any);
}
export async function removerFotoPaciente(id: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.foto(id)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("removerFotoPaciente", { url, result: { ok: true } });
}
//
// Anexos
//
export async function listarAnexos(id: string | number): Promise<any[]> {
const url = `${API_BASE}${PATHS.anexos(id)}`;
const res = await fetch(url, { method: "GET", headers: headers("json") });
const data = await parse<ApiOk<any[]>>(res);
logAPI("listarAnexos", { url, result: data });
return data?.data ?? (data as any);
}
export async function adicionarAnexo(id: string | number, file: File): Promise<any> {
const url = `${API_BASE}${PATHS.anexos(id)}`;
const fd = new FormData();
fd.append("arquivo", file);
const res = await fetch(url, { method: "POST", body: fd, headers: headers("form") });
const data = await parse<ApiOk<any>>(res);
logAPI("adicionarAnexo", { url, payload: { file: file.name }, result: data });
return data?.data ?? (data as any);
}
export async function removerAnexo(id: string | number, anexoId: string | number): Promise<void> {
const url = `${API_BASE}${PATHS.anexoId(id, anexoId)}`;
const res = await fetch(url, { method: "DELETE", headers: headers("json") });
await parse<any>(res);
logAPI("removerAnexo", { url, result: { ok: true } });
}
//
// Validações
//
export async function validarCPF(cpf: string): Promise<{ valido: boolean; existe: boolean; paciente_id: string | null }> {
const url = `${API_BASE}${PATHS.validarCPF}`;
const payload = { cpf };
const res = await fetch(url, { method: "POST", headers: headers("json"), body: JSON.stringify(payload) });
const data = await parse<ApiOk<{ valido: boolean; existe: boolean; paciente_id: string | null }>>(res);
logAPI("validarCPF", { url, payload, result: data });
return data?.data ?? (data as any);
}
export async function buscarCepAPI(cep: string): Promise<{ logradouro?: string; bairro?: string; localidade?: string; uf?: string; erro?: boolean }> {
const clean = (cep || "").replace(/\D/g, "");
const urlMock = `${API_BASE}${PATHS.cep(clean)}`;
try {
const res = await fetch(urlMock, { method: "GET", headers: headers("json") });
const data = await parse<any>(res); // pode vir direto ou dentro de {data}
logAPI("buscarCEP (mock)", { url: urlMock, payload: { cep: clean }, result: data });
const d = data?.data ?? data ?? {};
return {
logradouro: d.logradouro ?? d.street ?? "",
bairro: d.bairro ?? d.neighborhood ?? "",
localidade: d.localidade ?? d.city ?? "",
uf: d.uf ?? d.state ?? "",
erro: false,
};
} catch {
// fallback ViaCEP
const urlVia = `https://viacep.com.br/ws/${clean}/json/`;
const resV = await fetch(urlVia);
const jsonV = await resV.json().catch(() => ({}));
logAPI("buscarCEP (ViaCEP/fallback)", { url: urlVia, payload: { cep: clean }, result: jsonV });
if (jsonV?.erro) return { erro: true };
return {
logradouro: jsonV.logradouro ?? "",
bairro: jsonV.bairro ?? "",
localidade: jsonV.localidade ?? "",
uf: jsonV.uf ?? "",
erro: false,
};
}
}

View File

@ -0,0 +1,65 @@
export const mockAppointments = [
{
id: "1",
patient: "Ana Costa",
time: "2025-09-10T09:00",
duration: 30,
type: "consulta" as const,
status: "confirmed" as const,
professional: "1",
notes: "",
},
{
id: "2",
patient: "Pedro Alves",
time: "2025-09-10T10:30",
duration: 45,
type: "retorno" as const,
status: "pending" as const,
professional: "2",
notes: "",
},
{
id: "3",
patient: "Mariana Lima",
time: "2025-09-10T14:00",
duration: 60,
type: "exame" as const,
status: "confirmed" as const,
professional: "3",
notes: "",
},
];
export const mockWaitingList = [
{
id: "1",
name: "Ana Costa",
specialty: "Cardiologia",
preferredDate: "2025-09-12",
priority: "high" as const,
contact: "(11) 99999-9999",
},
{
id: "2",
name: "Pedro Alves",
specialty: "Dermatologia",
preferredDate: "2025-09-15",
priority: "medium" as const,
contact: "(11) 98888-8888",
},
{
id: "3",
name: "Mariana Lima",
specialty: "Ortopedia",
preferredDate: "2025-09-20",
priority: "low" as const,
contact: "(11) 97777-7777",
},
];
export const mockProfessionals = [
{ id: "1", name: "Dr. Carlos Silva", specialty: "Cardiologia" },
{ id: "2", name: "Dra. Maria Santos", specialty: "Dermatologia" },
{ id: "3", name: "Dr. João Oliveira", specialty: "Ortopedia" },
];

View File

@ -8,6 +8,11 @@
"name": "my-v0-project",
"version": "0.1.0",
"dependencies": {
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "latest",
"@radix-ui/react-alert-dialog": "latest",
@ -46,11 +51,11 @@
"geist": "^1.3.1",
"input-otp": "latest",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next": "14.2.16",
"next-themes": "latest",
"react": "^19",
"react": "^18",
"react-day-picker": "latest",
"react-dom": "^19",
"react-dom": "^18",
"react-hook-form": "latest",
"react-resizable-panels": "latest",
"recharts": "latest",
@ -63,8 +68,8 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
@ -90,16 +95,6 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@emnapi/runtime": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@ -138,6 +133,56 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@fullcalendar/core": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz",
"integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz",
"integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz",
"integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/react": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.19.tgz",
"integrity": "sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19",
"react": "^16.7.0 || ^17 || ^18 || ^19",
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz",
"integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@ -147,367 +192,6 @@
"react-hook-form": "^7.0.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@ -572,15 +256,15 @@
}
},
"node_modules/@next/env": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz",
"integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.16.tgz",
"integrity": "sha512-fLrX5TfJzHCbnZ9YUSnGW63tMV3L4nSfhgOQ0iCcX21Pt+VSTDuaLsSuL8J/2XAiVA5AnzvXDpf6pMs60QxOag==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz",
"integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.16.tgz",
"integrity": "sha512-uFT34QojYkf0+nn6MEZ4gIWQ5aqGF11uIZ1HSxG+cSbj+Mg3+tYm8qXYd3dKN5jqKUm5rBVvf1PBRO/MeQ6rxw==",
"cpu": [
"arm64"
],
@ -594,9 +278,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz",
"integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.16.tgz",
"integrity": "sha512-mCecsFkYezem0QiZlg2bau3Xul77VxUD38b/auAjohMA22G9KTJneUYMv78vWoCCFkleFAhY1NIvbyjj1ncG9g==",
"cpu": [
"x64"
],
@ -610,9 +294,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz",
"integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.16.tgz",
"integrity": "sha512-yhkNA36+ECTC91KSyZcgWgKrYIyDnXZj8PqtJ+c2pMvj45xf7y/HrgI17hLdrcYamLfVt7pBaJUMxADtPaczHA==",
"cpu": [
"arm64"
],
@ -626,9 +310,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz",
"integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.16.tgz",
"integrity": "sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==",
"cpu": [
"arm64"
],
@ -642,9 +326,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz",
"integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.16.tgz",
"integrity": "sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==",
"cpu": [
"x64"
],
@ -658,9 +342,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz",
"integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.16.tgz",
"integrity": "sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==",
"cpu": [
"x64"
],
@ -674,9 +358,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz",
"integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.16.tgz",
"integrity": "sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==",
"cpu": [
"arm64"
],
@ -689,10 +373,25 @@
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.16.tgz",
"integrity": "sha512-jhPl3nN0oKEshJBNDAo0etGMzv0j3q3VYorTSFqH1o3rwv1MQRdor27u1zhkgsHPNeY1jxcgyx1ZsCkDD1IHgg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz",
"integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.16.tgz",
"integrity": "sha512-OA7NtfxgirCjfqt+02BqxC3MIgM/JaGjw9tOe4fyZgPsqfseNiMPnCRP44Pfs+Gpo9zPN+SXaFsgP6vk8d571A==",
"cpu": [
"x64"
],
@ -2104,12 +1803,13 @@
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/node": {
@ -2461,24 +2161,32 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"version": "18.3.24",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
@ -2673,51 +2381,6 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"optional": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -2872,7 +2535,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -2992,7 +2655,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/immer": {
@ -3024,13 +2686,6 @@
"node": ">=12"
}
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT",
"optional": true
},
"node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@ -3041,6 +2696,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@ -3280,6 +2941,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lucide-react": {
"version": "0.454.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
@ -3357,42 +3030,41 @@
}
},
"node_modules/next": {
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz",
"integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.16.tgz",
"integrity": "sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.2.4",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"@next/env": "14.2.16",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.2.4",
"@next/swc-darwin-x64": "15.2.4",
"@next/swc-linux-arm64-gnu": "15.2.4",
"@next/swc-linux-arm64-musl": "15.2.4",
"@next/swc-linux-x64-gnu": "15.2.4",
"@next/swc-linux-x64-musl": "15.2.4",
"@next/swc-win32-arm64-msvc": "15.2.4",
"@next/swc-win32-x64-msvc": "15.2.4",
"sharp": "^0.33.5"
"@next/swc-darwin-arm64": "14.2.16",
"@next/swc-darwin-x64": "14.2.16",
"@next/swc-linux-arm64-gnu": "14.2.16",
"@next/swc-linux-arm64-musl": "14.2.16",
"@next/swc-linux-x64-gnu": "14.2.16",
"@next/swc-linux-x64-musl": "14.2.16",
"@next/swc-win32-arm64-msvc": "14.2.16",
"@next/swc-win32-ia32-msvc": "14.2.16",
"@next/swc-win32-x64-msvc": "14.2.16"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
@ -3402,9 +3074,6 @@
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
@ -3503,11 +3172,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@ -3534,15 +3216,16 @@
}
},
"node_modules/react-dom": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^19.1.1"
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
@ -3719,22 +3402,12 @@
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/server-only": {
@ -3743,56 +3416,6 @@
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -3821,9 +3444,9 @@
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
@ -3832,7 +3455,7 @@
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {

View File

@ -9,6 +9,11 @@
"start": "next start"
},
"dependencies": {
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "latest",
"@radix-ui/react-alert-dialog": "latest",
@ -47,11 +52,11 @@
"geist": "^1.3.1",
"input-otp": "latest",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next": "14.2.16",
"next-themes": "latest",
"react": "^19",
"react": "^18",
"react-day-picker": "latest",
"react-dom": "^19",
"react-dom": "^18",
"react-hook-form": "latest",
"react-resizable-panels": "latest",
"recharts": "latest",
@ -64,11 +69,11 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@ -1,9 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: var(--primary)
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@ -74,7 +74,7 @@
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
:root {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
@ -117,9 +117,17 @@
@layer base {
* {
@apply border-border outline-ring/50;
border-color: var(--border);
outline-color: var(--ring);
outline-width: 2px;
outline-style: solid;
outline-offset: 0.5px;
}
body {
@apply bg-background text-foreground;
background-color: var(--background);
color: var(--foreground);
}
}
.buttonText {
background-color: var(--primary);
}

View File

@ -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"]
}