feat: make side panel Cultivos section read-only and place bed badge on the left

This commit is contained in:
2026-08-11 13:10:55 +00:00
commit a17deb815e
117 changed files with 32924 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# MongoDB Configuration
MONGODB_URI="mongodb://127.0.0.1:27017"
DB_NAME="hospital"
# Default Admin Credentials (Used on initial deployment/startup)
ADMIN_DNI="12345678"
ADMIN_PASSWORD="admin123"
ADMIN_NOMBRE="Sistema"
ADMIN_APELLIDO="Administrador"
ADMIN_EMAIL="admin@hospital.local"
+12
View File
@@ -0,0 +1,12 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
*.db
*.db-wal
*.db-shm
server/data/
+75
View File
@@ -0,0 +1,75 @@
# Stage 1: Build frontend
FROM node:20-alpine AS builder
WORKDIR /build
ARG VITE_API_URL=/api
COPY package*.json ./
RUN npm install
COPY . .
RUN VITE_API_URL=$VITE_API_URL npm run build
# Stage 2: Production environment with Node.js, Nginx, and MongoDB Community Edition (ARM64 & AMD64 native support via Ubuntu 22.04)
FROM ubuntu:22.04
WORKDIR /app
# Prevent interactive prompts during apt install
ENV DEBIAN_FRONTEND=noninteractive
# Install dependencies needed for Node.js, MongoDB repo, and general setup
RUN apt-get update && apt-get install -y \
gnupg \
curl \
ca-certificates \
nginx \
procps \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Add MongoDB official GPG key and repository (MongoDB 8.0 for Ubuntu 22.04 Jammy - supports arm64 and amd64 natively)
RUN curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | \
gpg --dearmor -o /usr/share/keyrings/mongodb-server-8.0.gpg \
&& echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | \
tee /etc/apt/sources.list.d/mongodb-org-8.0.list
# Install MongoDB Community Edition
RUN apt-get update && apt-get install -y \
mongodb-org \
&& rm -rf /var/lib/apt/lists/*
# Copy built frontend to nginx html folder
COPY --from=builder /build/dist /usr/share/nginx/html
# Copy server files
COPY server/package*.json ./server/
RUN cd ./server && npm install --omit=dev
COPY server/ ./server/
# Configure Nginx for Debian
RUN rm -f /etc/nginx/sites-enabled/default
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create MongoDB data and run directories with correct permissions
RUN mkdir -p /data/db /var/log/mongodb /var/run/mongodb && \
chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb
# Expose volume for persistent database storage
VOLUME ["/data/db"]
# Copy entrypoint
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# ONLY expose port 80 (HTTP)
EXPOSE 80
ENV DB_NAME=hospital
ENV ADMIN_DNI=35242633
ENV ADMIN_PASSWORD=admin123
ENTRYPOINT ["/docker-entrypoint.sh"]
BIN
View File
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
# Sistema de Gestion de Historia Clinica Electronica
Sistema de gestion de historias clinicas ambulatorias y de internacion escrito en TypeScript, React, Vite y Talwind. Backend SQLite 3
## Deployment
En base a imagen de docker
1. mkdir administracionhce
2. cd administracionhce
3. git clone https://git.pi.neuroblastos.ar/snlavaise/administracionHospital #Clonar localmente el repositorio
4. docker compose up -d
El compose compilara la imagen de docker localmente e iniciara el contenedor
Accesible a través de http://localhost:8077
+1428
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "postcss.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+21
View File
@@ -0,0 +1,21 @@
services:
app:
build:
context: .
args:
- VITE_API_URL=/api
ports:
- "8084:80"
volumes:
- mongodb_data:/data/db
environment:
DB_NAME: hospital
ADMIN_DNI: "35242633"
ADMIN_PASSWORD: admin123
ADMIN_NOMBRE: "Sistema"
ADMIN_APELLIDO: "Administrador"
ADMIN_EMAIL: "admin@hospital.local"
restart: unless-stopped
volumes:
mongodb_data:
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
# Ensure permissions on volume and logs directory
mkdir -p /data/db /var/log/mongodb /var/run/mongodb
chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb
echo "Starting local MongoDB server (only accessible from inside container)..."
# Start mongod as default mongodb user, bound to localhost (127.0.0.1)
if ! su -s /bin/bash -c "mongod --dbpath /data/db --bind_ip 127.0.0.1 --fork --logpath /var/log/mongodb/mongod.log" mongodb; then
echo "ERROR: Failed to fork mongod process."
if [ -f /var/log/mongodb/mongod.log ]; then
echo "=== MongoDB Log (/var/log/mongodb/mongod.log) ==="
cat /var/log/mongodb/mongod.log
fi
exit 1
fi
echo "Waiting for MongoDB to be ready..."
READY=0
for i in $(seq 1 30); do
if mongosh --eval "db.adminCommand('ping')" --quiet &>/dev/null; then
READY=1
echo "MongoDB is ready."
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "ERROR: MongoDB did not become ready within 30 seconds!"
if [ -f /var/log/mongodb/mongod.log ]; then
echo "=== MongoDB Log (/var/log/mongodb/mongod.log) ==="
tail -n 100 /var/log/mongodb/mongod.log
fi
exit 1
fi
echo "Starting Nginx reverse proxy..."
if ! nginx; then
echo "ERROR: Failed to start Nginx."
exit 1
fi
echo "Starting Node.js backend connected to local MongoDB..."
# Start the Node.js API server using MongoDB
exec node server/api-mongodb.js
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+753
View File
@@ -0,0 +1,753 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Generador HC Ingreso Santojanni</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script>
<style>
body { font-family: Arial, sans-serif; background: #f0f4f8; display: flex;
flex-direction: column; align-items: center; padding: 40px; }
h1 { font-size: 1.2rem; color: #1a3a5c; margin-bottom: 8px; }
p { color: #555; font-size: .9rem; margin-bottom: 24px; }
button { background: #1a3a5c; color: #fff; border: none; padding: 14px 36px;
font-size: 1rem; border-radius: 6px; cursor: pointer; }
button:hover { background: #25547a; }
#status { margin-top: 16px; font-size: .85rem; color: #444; }
</style>
</head>
<body>
<h1>Historia Clínica de Ingreso Hospital Donación F. Santojanni</h1>
<p>Hace clic en el botón para generar el PDF con las 5 páginas del formulario en blanco.</p>
<button onclick="generarPDF()">Generar PDF</button>
<div id="status"></div>
<script>
/* ============================================================
CONSTANTES GLOBALES
============================================================ */
const A4W = 595.28;
const A4H = 841.89;
const ML = 25; // margen izquierdo
const MR = 25; // margen derecho
const INNER = A4W - ML - MR; // ancho útil: 545.28
// Colores
const NEGRO = PDFLib.rgb(0, 0, 0);
const GRIS_HEAD = PDFLib.rgb(0.82, 0.82, 0.82); // fondo encabezados de sección
const GRIS_LABEL = PDFLib.rgb(0.93, 0.93, 0.93); // fondo celdas etiqueta
const BLANCO = PDFLib.rgb(1, 1, 1);
/* ============================================================
HELPERS
============================================================ */
/** Dibuja un rectángulo con borde negro (relleno opcional). */
function rect(page, x, y, w, h, { fill = null, borderWidth = 0.5 } = {}) {
if (fill) page.drawRectangle({ x, y, width: w, height: h, color: fill });
page.drawRectangle({ x, y, width: w, height: h,
borderColor: NEGRO, borderWidth, color: fill ?? PDFLib.rgb(1,1,1) });
}
/** Dibuja texto centrado horizontalmente en un rango x→x+w. */
function textCenter(page, txt, x, y, w, { font, size = 8, color = NEGRO } = {}) {
const tw = font.widthOfTextAtSize(txt, size);
page.drawText(txt, { x: x + (w - tw) / 2, y, size, font, color });
}
/** Caja de sección con fondo gris y título centrado en negrita. */
function sectionHeader(page, label, x, y, w, h = 14, { bold, regular } = {}) {
rect(page, x, y, w, h, { fill: GRIS_HEAD });
textCenter(page, label, x, y + 3, w, { font: bold, size: 8 });
return y - h; // retorna la Y del borde inferior de la caja
}
/** Celda con etiqueta izquierda (gris) y área de escritura (blanca). */
function labelCell(page, label, x, y, labelW, totalW, h, { regular, bold, fontSize = 7 } = {}) {
// fondo etiqueta
page.drawRectangle({ x, y, width: labelW, height: h, color: GRIS_LABEL });
page.drawRectangle({ x, y, width: labelW, height: h,
borderColor: NEGRO, borderWidth: 0.4, color: GRIS_LABEL });
// área escribible
page.drawRectangle({ x: x + labelW, y, width: totalW - labelW, height: h,
borderColor: NEGRO, borderWidth: 0.4, color: BLANCO });
page.drawText(label, { x: x + 2, y: y + 2.5, size: fontSize, font: regular, color: NEGRO });
}
/** Línea horizontal simple. */
function hLine(page, x, y, w) {
page.drawLine({ start: { x, y }, end: { x: x + w, y }, thickness: 0.4, color: NEGRO });
}
/** Línea vertical simple. */
function vLine(page, x, y1, y2) {
page.drawLine({ start: { x, y: y1 }, end: { x, y: y2 }, thickness: 0.4, color: NEGRO });
}
/* ============================================================
CABECERA COMÚN (páginas 1-5)
============================================================ */
function drawPageHeader(page, pageNum, bold, regular) {
const top = A4H - 18;
// Logo placeholder izquierdo (círculo con "BA")
page.drawCircle({ x: ML + 12, y: top, size: 11, color: PDFLib.rgb(0.2, 0.4, 0.7) });
page.drawText("BA", { x: ML + 6, y: top - 4, size: 7, font: bold, color: BLANCO });
// Texto central
const titulo1 = "HOSPITAL DONACIÓN F. SANTOJANNI DIVISIÓN CLÍNICA MÉDICA";
const titulo2 = "HISTORIA CLÍNICA DE INGRESO";
const tw1 = bold.widthOfTextAtSize(titulo1, 9);
const tw2 = bold.widthOfTextAtSize(titulo2, 9);
page.drawText(titulo1, { x: (A4W - tw1) / 2, y: top + 4, size: 9, font: bold, color: NEGRO });
page.drawText(titulo2, { x: (A4W - tw2) / 2, y: top - 6, size: 9, font: bold, color: NEGRO });
// Logo placeholder derecho
page.drawCircle({ x: A4W - ML - 12, y: top, size: 11, color: PDFLib.rgb(0.2, 0.4, 0.7) });
page.drawText("H", { x: A4W - ML - 15, y: top - 4, size: 7, font: bold, color: BLANCO });
// Número de página
const pTxt = `${pageNum}`;
page.drawText(pTxt, { x: (A4W - regular.widthOfTextAtSize(pTxt, 8)) / 2,
y: 15, size: 8, font: regular, color: NEGRO });
// Línea separadora
hLine(page, ML, A4H - 30, INNER);
return A4H - 38; // Y de inicio del contenido
}
/* ============================================================
PÁGINA 1 Datos filiatorios / Motivo / Enfermedad actual
============================================================ */
async function buildPage1(pdfDoc, bold, regular) {
const page = pdfDoc.addPage([A4W, A4H]);
let y = drawPageHeader(page, 1, bold, regular);
/* --- DATOS FILIATORIOS --- */
y = sectionHeader(page, "DATOS FILIATORIOS", ML, y, INNER, 14, { bold, regular });
// Fila 1: fecha ingreso hospital | fecha ingreso clínica médica | habitación
const colW = [INNER * 0.30, INNER * 0.42, INNER * 0.28];
const rowH = 12;
let cx = ML;
["FECHA DE INGRESO AL HOSPITAL (DD/MM/AA)", "FECHA DE INGRESO A CLÍNICA MÉDICA (DD/MM/AA)", "HABITACIÓN"].forEach((lbl, i) => {
rect(page, cx, y - rowH, colW[i], rowH);
page.drawText(lbl, { x: cx + 2, y: y - rowH + 3.5, size: 5.5, font: regular, color: NEGRO });
cx += colW[i];
});
// Fecha clínica médica pre-cargada (16/04/2026)
const fechaTxt = "16/04/2026";
const mid = ML + colW[0];
page.drawText(fechaTxt, { x: mid + (colW[1] - bold.widthOfTextAtSize(fechaTxt, 9)) / 2,
y: y - rowH + 3, size: 9, font: bold, color: NEGRO });
y -= rowH;
// Fila 2: Apellido y nombre | DNI/CI
const mitad = INNER / 2;
labelCell(page, "APELLIDO y NOMBRE", ML, y - rowH, 45, mitad, rowH, { regular, bold });
labelCell(page, "DNI / C.I", ML + mitad, y - rowH, 30, mitad, rowH, { regular, bold });
y -= rowH;
// Fila 3
labelCell(page, "FECHA DE NACIMIENTO", ML, y - rowH, 50, mitad, rowH, { regular, bold });
labelCell(page, "HISTORIA CLINICA", ML + mitad, y - rowH, 42, mitad, rowH, { regular, bold });
y -= rowH;
// Fila 4
labelCell(page, "EDAD", ML, y - rowH, 22, mitad, rowH, { regular, bold });
labelCell(page, "OBRA SOCIAL", ML + mitad, y - rowH, 42, mitad, rowH, { regular, bold });
y -= rowH;
// Fila 5
labelCell(page, "NACIONALIDAD", ML, y - rowH, 42, mitad, rowH, { regular, bold });
// DERIVADO DE: con valor "Guardia Externa"
labelCell(page, "DERIVADO DE:", ML + mitad, y - rowH, 42, mitad, rowH, { regular, bold });
page.drawText("Guardia Externa", { x: ML + mitad + 44, y: y - rowH + 3,
size: 7, font: regular, color: NEGRO });
y -= rowH;
// Fila 6
labelCell(page, "DIRECCIÓN", ML, y - rowH, 30, mitad, rowH, { regular, bold });
labelCell(page, "APACHE / MORTALIDAD", ML + mitad, y - rowH, 55, mitad, rowH, { regular, bold });
y -= rowH;
// Fila 7: Teléfono de contacto (ancho completo)
labelCell(page, "Teléfono de contacto:", ML, y - rowH, 60, INNER, rowH, { regular, bold });
y -= rowH + 4;
/* --- MOTIVO DE CONSULTA --- */
y = sectionHeader(page, "MOTIVO DE CONSULTA", ML, y, INNER, 13, { bold, regular });
const motivoH = 24;
rect(page, ML, y - motivoH, INNER, motivoH);
y -= motivoH + 4;
/* --- ENFERMEDAD ACTUAL --- */
y = sectionHeader(page, "ENFERMEDAD ACTUAL", ML, y, INNER, 13, { bold, regular });
const eaH = 220;
rect(page, ML, y - eaH, INNER, eaH);
y -= eaH + 4;
/* --- ANTECEDENTES DE ENFERMEDAD ACTUAL --- */
y = sectionHeader(page, "ANTECEDENTES DE ENFERMEDAD ACTUAL", ML, y, INNER, 13, { bold, regular });
const antH = Math.max(y - 30, 40); // lo que quede en la página
rect(page, ML, y - antH, INNER, antH);
}
/* ============================================================
PÁGINA 2 Antecedentes personales / Medicación / Hábitos
============================================================ */
async function buildPage2(pdfDoc, bold, regular) {
const page = pdfDoc.addPage([A4W, A4H]);
let y = drawPageHeader(page, 2, bold, regular);
/* --- ANTECEDENTES PERSONALES --- */
y = sectionHeader(page, "ANTECEDENTES PERSONALES", ML, y, INNER, 13, { bold, regular });
const apSections = [
{ label: "CLÍNICOS", h: 38 },
{ label: "QUIRÚRGICOS /\nTRAUMATOLÓGICOS", h: 38 },
{ label: "GINECO-OBSTÉTRICOS", h: 38 },
{ label: "ALÉRGICOS /\nTRANSFUSIONALES", h: 38 },
];
const labelW = 80;
apSections.forEach(sec => {
rect(page, ML, y - sec.h, labelW, sec.h, { fill: GRIS_LABEL });
rect(page, ML + labelW, y - sec.h, INNER - labelW, sec.h);
// texto label multilinea centrado verticalmente
sec.label.split("\n").forEach((ln, i) => {
page.drawText(ln, { x: ML + 2, y: y - sec.h / 2 + (sec.label.includes("\n") ? (i === 0 ? 5 : -4) : -2),
size: 7, font: regular, color: NEGRO });
});
y -= sec.h;
});
y -= 4;
/* --- MEDICACIÓN HABITUAL --- */
y = sectionHeader(page, "MEDICACIÓN HABITUAL", ML, y, INNER, 13, { bold, regular });
const medH = 9;
const medColW = INNER / 2;
for (let i = 1; i <= 20; i++) {
const col = i <= 10 ? 0 : 1;
const row = i <= 10 ? i - 1 : i - 11;
const cx2 = ML + col * medColW;
const cy = y - row * medH - medH;
// número
rect(page, cx2, cy, 10, medH);
page.drawText(`${i}`, { x: cx2 + 2, y: cy + 2, size: 6.5, font: regular, color: NEGRO });
// área
rect(page, cx2 + 10, cy, medColW - 10, medH);
}
y -= 10 * medH + 4;
/* --- HÁBITOS + ANTECEDENTES HEREDOFAMILIARES (lado a lado) --- */
const halfW = INNER / 2 - 2;
const habX = ML;
const aheX = ML + halfW + 4;
// Hábitos
y = sectionHeader(page, "HÁBITOS", habX, y, halfW, 13, { bold, regular });
// tabla de hábitos
const habRows = [
["Apetito", "Conservado", "Sí"],
["Sueño", "Conservado", "Sí"],
["Catarsis", "Conservado", "Sí"],
["Diuresis", "Conservado", "Sí"],
];
let hy = y;
const hRowH = 9;
habRows.forEach(([item, val, check]) => {
const widths = [35, 40, 12, halfW - 87];
let hx = habX;
[item, val, check, "Aclare:"].forEach((txt, i) => {
rect(page, hx, hy - hRowH, widths[i], hRowH);
page.drawText(txt, { x: hx + 2, y: hy - hRowH + 2.5, size: 6, font: regular, color: NEGRO });
hx += widths[i];
});
hy -= hRowH;
});
hy -= 2;
// Tabaquismo / Alcoholismo / Drogas
const addictionRows = [
{ label: "Tabaquismo", check: "Sí", unit: "paq/y" },
{ label: "Alcoholismo", check: "No", unit: "gr/d" },
{ label: "Drogas\nilícitas", check: "No", unit: "Cuales" },
];
addictionRows.forEach(row => {
const aw = [40, 14, halfW - 54];
let ax = habX;
[row.label, row.check, row.unit + " Desde/Hasta"].forEach((txt, i) => {
rect(page, ax, hy - hRowH * 1.5, aw[i], hRowH * 1.5);
page.drawText(txt.split("\n")[0], { x: ax + 2, y: hy - hRowH * 1.5 + 6, size: 6, font: regular, color: NEGRO });
if (txt.includes("\n")) page.drawText(txt.split("\n")[1], { x: ax + 2, y: hy - hRowH * 1.5 + 1, size: 6, font: regular, color: NEGRO });
ax += aw[i];
});
hy -= hRowH * 1.5;
});
// Antecedentes heredofamiliares (misma Y de inicio)
let aheY = y + 13; // misma línea que "HÁBITOS" header
aheY = sectionHeader(page, "ANTECEDENTES HEREDOFAMILIARES", aheX, aheY, halfW, 13, { bold, regular });
const aheRows = [
{ label: "Madre", extra: ["Vive", "No", "Años:"] },
{ label: "Patología:" },
{ label: "Padre", extra: ["Vive", "No", "Años:"] },
{ label: "Patología:" },
{ label: "Hermanos", extra: ["N.º", "", "Años:"] },
{ label: "Patología:" },
{ label: "Hijos", extra: ["N.º", "", "Años:"] },
{ label: "Patología:" },
{ label: "Comentarios" },
];
const aRowH = 10;
aheRows.forEach(row => {
rect(page, aheX, aheY - aRowH, halfW, aRowH);
page.drawText(row.label, { x: aheX + 2, y: aheY - aRowH + 2.5, size: 6.5, font: regular, color: NEGRO });
if (row.extra) {
const ex = row.extra;
const ew = [(halfW - 50) / 3, (halfW - 50) / 3, (halfW - 50) / 3];
let ex2 = aheX + 50;
ex.forEach((txt, i) => {
rect(page, ex2, aheY - aRowH, ew[i], aRowH);
page.drawText(txt, { x: ex2 + 2, y: aheY - aRowH + 2.5, size: 6, font: regular, color: NEGRO });
ex2 += ew[i];
});
}
aheY -= aRowH;
});
}
/* ============================================================
PÁGINA 3 Contactos / Vacunas / Determinantes / Examen físico
============================================================ */
async function buildPage3(pdfDoc, bold, regular) {
const page = pdfDoc.addPage([A4W, A4H]);
let y = drawPageHeader(page, 3, bold, regular);
const halfW = INNER / 2 - 2;
/* --- CONTACTOS + VACUNACIÓN (lado a lado) --- */
// Headers a la misma Y
const yStart = y;
sectionHeader(page, "CONTACTOS", ML, yStart, halfW, 13, { bold, regular });
sectionHeader(page, "VACUNACIÓN/SEROLOGÍAS", ML + halfW + 4, yStart, halfW, 13, { bold, regular });
y -= 13;
// Contactos
const contactos = [
{ label: "Animales", val: "No", extra: "Cuáles?" },
{ label: "TBC", val: "No", extra: "Cuándo?" },
{ label: "Chagas", val: "No", extra: "Cómo?" },
{ label: "HIV", val: "No", extra: "Conducta\nriesgo?" },
{ label: "Otros", val: "No", extra: "Cuál/es?" },
];
let cy2 = y;
const cRowH = 16;
contactos.forEach(c => {
const cw = [40, 18, halfW - 58];
let cx2 = ML;
[c.label, c.val, c.extra].forEach((txt, i) => {
rect(page, cx2, cy2 - cRowH, cw[i], cRowH);
txt.split("\n").forEach((ln, li) => {
page.drawText(ln, { x: cx2 + 2, y: cy2 - cRowH + (cRowH / 2) + 1 - li * 7, size: 6.5, font: regular, color: NEGRO });
});
cx2 += cw[i];
});
cy2 -= cRowH;
});
// Vacunación
const vacX = ML + halfW + 4;
let vy = y;
// cabecera sub
const vacHeaders = ["Dosis", "Ultima", "Nombre comercial"];
const vacW = [halfW * 0.22, halfW * 0.22, halfW * 0.56];
// fila "Vacuna COVID" + sub-encabezados
const vacRowH = 10;
rect(page, vacX, vy - vacRowH, halfW * 0.3, vacRowH, { fill: GRIS_LABEL });
page.drawText("Vacuna COVID", { x: vacX + 2, y: vy - vacRowH + 2.5, size: 6.5, font: regular, color: NEGRO });
let vx = vacX + halfW * 0.3;
vacHeaders.forEach((h, i) => {
rect(page, vx, vy - vacRowH, vacW[i], vacRowH, { fill: GRIS_LABEL });
page.drawText(h, { x: vx + 2, y: vy - vacRowH + 2.5, size: 6, font: regular, color: NEGRO });
vx += vacW[i];
});
vy -= vacRowH;
// valor dosis 3
rect(page, vacX, vy - vacRowH, halfW * 0.3, vacRowH, { fill: GRIS_LABEL });
rect(page, vacX + halfW * 0.3, vy - vacRowH, vacW[0], vacRowH);
page.drawText("3", { x: vacX + halfW * 0.3 + 4, y: vy - vacRowH + 2.5, size: 7, font: bold, color: NEGRO });
rect(page, vacX + halfW * 0.3 + vacW[0], vy - vacRowH, vacW[1] + vacW[2], vacRowH);
vy -= vacRowH;
const vacRows = [
{ label: "Antigripal", check: "No" },
{ label: "Antineumocócica", check: "No" },
{ label: "Doble Adultos", check: "No" },
{ label: "Otras", check: "" },
];
const vacRH = 10;
vacRows.forEach(row => {
rect(page, vacX, vy - vacRH, halfW * 0.3, vacRH, { fill: GRIS_LABEL });
page.drawText(row.label, { x: vacX + 2, y: vy - vacRH + 2.5, size: 6.5, font: regular, color: NEGRO });
rect(page, vacX + halfW * 0.3, vy - vacRH, vacW[0], vacRH);
page.drawText(row.check, { x: vacX + halfW * 0.3 + 2, y: vy - vacRH + 2.5, size: 7, font: regular, color: NEGRO });
rect(page, vacX + halfW * 0.3 + vacW[0], vy - vacRH, vacW[1] + vacW[2], vacRH);
vy -= vacRH;
});
// Serologías
rect(page, vacX, vy - vacRH, halfW * 0.3, vacRH, { fill: GRIS_LABEL });
page.drawText("Serologías", { x: vacX + 2, y: vy - vacRH + 2.5, size: 6.5, font: regular, color: NEGRO });
rect(page, vacX + halfW * 0.3, vy - vacRH, halfW * 0.7, vacRH);
const resultTxt = "Resultado";
page.drawText(resultTxt, { x: vacX + halfW * 0.3 + (halfW * 0.7 - regular.widthOfTextAtSize(resultTxt, 7)) / 2, y: vy - vacRH + 2.5, size: 7, font: regular, color: NEGRO });
vy -= vacRH;
// 2 filas vacías de serologías
for (let i = 0; i < 2; i++) {
rect(page, vacX, vy - vacRH, halfW, vacRH);
vy -= vacRH;
}
y = Math.min(cy2, vy) - 4;
/* --- DETERMINANTES SOCIO-EPIDEMIOLÓGICOS --- */
y = sectionHeader(page, "DETERMINANTES SOCIO-EPIDEMIOLÓGICOS", ML, y, INNER, 13, { bold, regular });
const detRows = [
{ label: "LUGAR DE NACIMIENTO" },
{ label: "RESIDENCIA PASADA Y ACTUAL" },
{ label: "VIVIENDA", special: "vivienda" },
{ label: "NIVEL DE ESCOLARIDAD ALCANZADO" },
{ label: "OCUPACIÓN/OFICIO/PROFESIÓN" },
{ label: "VIAJES RECIENTES" },
];
const detH = 12;
const detLW = 90;
detRows.forEach(row => {
if (row.special === "vivienda") {
// dos sub-filas: Material/Sanitarios + Agua/Cloacas
rect(page, ML, y - detH, detLW, detH * 2);
page.drawText(row.label, { x: ML + 2, y: y - detH + 2.5, size: 7, font: regular, color: NEGRO });
// sub-fila 1
const subW = (INNER - detLW) / 4;
const subLabels1 = ["Material", "No", "Sanitarios completos", "No"];
let sx = ML + detLW;
subLabels1.forEach((lbl, i) => {
const fill = (i % 2 === 0) ? GRIS_LABEL : null;
rect(page, sx, y - detH, subW, detH, { fill: fill || BLANCO });
page.drawText(lbl, { x: sx + 2, y: y - detH + 3, size: 6.5, font: regular, color: NEGRO });
sx += subW;
});
y -= detH;
// sub-fila 2
const subLabels2 = ["Agua Potable", "No", "Cloacas", "No"];
sx = ML + detLW;
subLabels2.forEach((lbl, i) => {
const fill = (i % 2 === 0) ? GRIS_LABEL : null;
rect(page, sx, y - detH, subW, detH, { fill: fill || BLANCO });
page.drawText(lbl, { x: sx + 2, y: y - detH + 3, size: 6.5, font: regular, color: NEGRO });
sx += subW;
});
y -= detH;
} else {
rect(page, ML, y - detH, detLW, detH, { fill: GRIS_LABEL });
page.drawText(row.label, { x: ML + 2, y: y - detH + 3, size: 7, font: regular, color: NEGRO });
rect(page, ML + detLW, y - detH, INNER - detLW, detH);
y -= detH;
}
});
y -= 4;
/* --- EXAMEN FÍSICO --- */
y = sectionHeader(page, "EXAMEN FÍSICO", ML, y, INNER, 13, { bold, regular });
// Fila 1: TA / FC / FR / Temp / Sat / FiO2
const efRow1 = [
{ label: "TENSIÓN ARTERIAL", w: INNER * 0.18 },
{ label: "FRECUENCIA\nCARDÍACA", w: INNER * 0.14 },
{ label: "FRECUENCIA\nRESPIRATORIA", w: INNER * 0.14 },
{ label: "TEMPERATURA\nAXILAR", w: INNER * 0.14 },
{ label: "SATUROMETRÍA", w: INNER * 0.14 },
{ label: "FIO2(FRACCIÓN\nINSPIRADA DE OXIGENO)", w: INNER * 0.26 },
];
const efH1 = 22;
const efH1b = 12;
let ex = ML;
efRow1.forEach(col => {
rect(page, ex, y - efH1, col.w, efH1 / 2, { fill: GRIS_LABEL });
col.label.split("\n").forEach((ln, i) => {
page.drawText(ln, { x: ex + 2, y: y - efH1 / 2 + (col.label.includes("\n") ? (i === 0 ? 3 : -4) : -1),
size: 5.5, font: regular, color: NEGRO });
});
rect(page, ex, y - efH1, col.w, efH1);
ex += col.w;
});
y -= efH1 + 2;
// Fila 2: SNG / SV / AVP / AVC / Peso-Talla-IMC
const efRow2 = [
{ label: "SONDA\nNASOGÁSTRICA", val: "No", w: INNER * 0.14 },
{ label: "SONDA\nVESICAL", val: "Sonda Vesical",w: INNER * 0.16 },
{ label: "ACCESO VENOSO\nPERIFÉRICO", val: "Si - MSI", w: INNER * 0.16 },
{ label: "ACCESO VENOSO CENTRAL", val: "No", w: INNER * 0.27 },
{ label: "PESO / TALLA / IMC", val: "", w: INNER * 0.27 },
];
const ef2H = 18;
ex = ML;
efRow2.forEach(col => {
rect(page, ex, y - ef2H, col.w, ef2H / 2, { fill: GRIS_LABEL });
col.label.split("\n").forEach((ln, li) => {
page.drawText(ln, { x: ex + 2, y: y - ef2H / 2 + (col.label.includes("\n") ? (li === 0 ? 3 : -4) : -1),
size: 5.5, font: regular, color: NEGRO });
});
rect(page, ex, y - ef2H, col.w, ef2H);
page.drawText(col.val, { x: ex + 2, y: y - ef2H + 3, size: 6.5, font: regular, color: NEGRO });
ex += col.w;
});
y -= ef2H + 2;
// Sistemas
const sistemas = [
"SISTEMA\nNERVIOSO\nCENTRAL",
"CARDIOVASCULAR",
"RESPIRATORIO",
"DIGESTIVO",
"GENITO/URINARIO",
"SISTEMA OSTEO \nMUSCULO -\nARTICULAR",
];
const sysLW = 60;
const sysH = 28;
sistemas.forEach(sis => {
rect(page, ML, y - sysH, sysLW, sysH, { fill: GRIS_LABEL });
sis.split("\n").forEach((ln, i) => {
const lines = sis.split("\n").length;
const baseY = y - sysH + sysH / 2 - 3;
page.drawText(ln, { x: ML + 2, y: baseY + (lines - 1 - i) * 7, size: 6.5, font: regular, color: NEGRO });
});
rect(page, ML + sysLW, y - sysH, INNER - sysLW, sysH);
y -= sysH;
});
}
/* ============================================================
PÁGINA 4 Laboratorio / Bacteriología / ECG / Estudios
============================================================ */
async function buildPage4(pdfDoc, bold, regular) {
const page = pdfDoc.addPage([A4W, A4H]);
let y = drawPageHeader(page, 4, bold, regular);
/* --- LABORATORIO --- */
y = sectionHeader(page, "LABORATORIO", ML, y, INNER, 13, { bold, regular });
// Parámetros en columnas de 3 grupos (label + espacio)
const labParams = [
["HTO (%)", "GLUC (mg/dl)", "Na+ (mEq/L)", "BT (mg/dl)", "TP (%)"],
["HB (G/DL)", "UREA (mg/dl)", "K+ (mEq/L)", "BD (mg/dl)", "KPTT (seg)"],
["GB (X10⁹/L)", "CREAT (mg/dl)", "Cl- (mEq/L)", "GOT (UI/L)", "RIN"],
["PQ (X10⁹/L)", "", "P- (mEq/L)", "GPT (UI/L)", "PROT (g/dL)"],
["", "", "Mg+ (mEq/L)", "FAL (UI/L)", "ALB (g/dL)"],
];
const labCols = 5;
const labColW = INNER / labCols;
const labRowH = 11;
const labLabelW = labColW * 0.55;
labParams.forEach((row, ri) => {
let lx = ML;
row.forEach((param, ci) => {
if (param) {
rect(page, lx, y - labRowH, labLabelW, labRowH, { fill: GRIS_LABEL });
page.drawText(param, { x: lx + 2, y: y - labRowH + 3, size: 5.5, font: regular, color: NEGRO });
rect(page, lx + labLabelW, y - labRowH, labColW - labLabelW, labRowH);
}
lx += labColW;
});
y -= labRowH;
});
// EAB
y -= 2;
const eabLabels = ["EAB", "PH", "CO2", "O2", "HCO3", "EB", "SAT", "A.L", "FIO2"];
const eabW = [28, 30, 30, 30, 35, 30, 30, 30, 30];
const eabH = 11;
let eabX = ML;
eabLabels.forEach((lbl, i) => {
rect(page, eabX, y - eabH, eabW[i], eabH / 2, { fill: GRIS_LABEL });
page.drawText(lbl, { x: eabX + 2, y: y - eabH / 2 + 1, size: 6, font: bold, color: NEGRO });
rect(page, eabX, y - eabH, eabW[i], eabH);
eabX += eabW[i];
});
y -= eabH + 2;
// OTROS / FENA
rect(page, ML, y, INNER, 13, { fill: GRIS_LABEL });
page.drawText("OTROS (INCLUYE LÍQUIDOS)", { x: ML + 2, y: y + 4, size: 7, font: bold, color: NEGRO });
y -= 0;
rect(page, ML, y - 36, INNER, 36);
page.drawText("FENA", { x: ML + 2, y: y - 8, size: 7, font: regular, color: NEGRO });
y -= 36 + 13 + 4;
/* --- BACTERIOLOGÍA --- */
y = sectionHeader(page, "BACTERIOLOGÍA", ML, y, INNER, 13, { bold, regular });
const bactHeaders = ["Fecha", "Cultivo", "Resultado", "Sensibilidad"];
const bactW = [INNER * 0.18, INNER * 0.27, INNER * 0.27, INNER * 0.28];
const bactH = 10;
let bx = ML;
bactHeaders.forEach((h, i) => {
rect(page, bx, y - bactH, bactW[i], bactH, { fill: GRIS_LABEL });
textCenter(page, h, bx, y - bactH + 2, bactW[i], { font: bold, size: 7 });
bx += bactW[i];
});
y -= bactH;
for (let i = 0; i < 5; i++) {
bx = ML;
bactHeaders.forEach((_, j) => { rect(page, bx, y - bactH, bactW[j], bactH); bx += bactW[j]; });
y -= bactH;
}
y -= 4;
/* --- ECG --- */
y = sectionHeader(page, "ELECTROCARDIOGRAMA", ML, y, INNER, 13, { bold, regular });
// Fila con fecha + interpretación
const ecgH = 10;
const ecgLW = 28;
const ecgRW = INNER / 2;
const ecgParams = [
["FECHA", "", ecgRW],
["Ritmo", "PR"],
["FC", "Onda T"],
["Eje", "QT"],
["Onda P",""],
["QRS", ""],
];
// Primera fila: fecha + "INTERPRETACIÓN DIAGNÓSTICA:" (columna derecha grande)
rect(page, ML, y - ecgH, ecgLW, ecgH, { fill: GRIS_LABEL });
page.drawText("FECHA", { x: ML + 2, y: y - ecgH + 3, size: 7, font: bold, color: NEGRO });
rect(page, ML + ecgLW, y - ecgH, ecgRW - ecgLW, ecgH);
rect(page, ML + ecgRW, y - ecgH, INNER - ecgRW, ecgH, { fill: GRIS_LABEL });
page.drawText("INTERPRETACIÓN DIAGNÓSTICA:", { x: ML + ecgRW + 2, y: y - ecgH + 3, size: 7, font: bold, color: NEGRO });
y -= ecgH;
const ecgLeft = [["Ritmo"],["FC"],["Eje"],["Onda P"],["QRS"]];
const ecgRight = [["PR"],["Onda T"],["QT"],[""],[""]];
const ecgDataH = 10;
const lhW = INNER / 4;
// zona interpretación (rectángulo derecho que abarca todas las filas)
rect(page, ML + ecgRW, y - ecgDataH * 5, INNER - ecgRW, ecgDataH * 5);
ecgLeft.forEach((row, i) => {
rect(page, ML, y - ecgDataH, lhW * 0.5, ecgDataH, { fill: GRIS_LABEL });
page.drawText(row[0], { x: ML + 2, y: y - ecgDataH + 3, size: 7, font: regular, color: NEGRO });
rect(page, ML + lhW * 0.5, y - ecgDataH, lhW - lhW * 0.5, ecgDataH);
if (ecgRight[i][0]) {
rect(page, ML + lhW, y - ecgDataH, lhW * 0.5, ecgDataH, { fill: GRIS_LABEL });
page.drawText(ecgRight[i][0], { x: ML + lhW + 2, y: y - ecgDataH + 3, size: 7, font: regular, color: NEGRO });
rect(page, ML + lhW * 1.5, y - ecgDataH, lhW - lhW * 0.5, ecgDataH);
}
y -= ecgDataH;
});
y -= 4;
/* --- ESTUDIOS COMPLEMENTARIOS --- */
y = sectionHeader(page, "ESTUDIOS COMPLEMENTARIOS", ML, y, INNER, 13, { bold, regular });
const estHeaders = ["Fecha", "Estudio", "Descripción"];
const estW = [INNER * 0.14, INNER * 0.16, INNER * 0.70];
let estX = ML;
estHeaders.forEach((h, i) => {
rect(page, estX, y - ecgH, estW[i], ecgH, { fill: GRIS_LABEL });
page.drawText(h, { x: estX + 2, y: y - ecgH + 3, size: 7, font: bold, color: NEGRO });
estX += estW[i];
});
y -= ecgH;
const estRowH = 22;
for (let i = 0; i < 4; i++) {
estX = ML;
estHeaders.forEach((_, j) => { rect(page, estX, y - estRowH, estW[j], estRowH); estX += estW[j]; });
// Fecha marcadas: /03/ y /04/
if (i < 2) {
page.drawText(i === 0 ? "/03/." : "/04/.", { x: ML + 2, y: y - estRowH + 8, size: 10, font: bold, color: NEGRO });
}
y -= estRowH;
}
}
/* ============================================================
PÁGINA 5 Diagnóstico / Plan diagnóstico / Plan terapéutico
============================================================ */
async function buildPage5(pdfDoc, bold, regular) {
const page = pdfDoc.addPage([A4W, A4H]);
let y = drawPageHeader(page, 5, bold, regular);
/* --- IMPRESIÓN DIAGNÓSTICA POR SINDROME --- */
y = sectionHeader(page, "IMPRESIÓN DIAGNÓSTICA POR SINDROME", ML, y, INNER, 13, { bold, regular });
const idH = 200;
rect(page, ML, y - idH, INNER, idH);
y -= idH + 4;
/* --- PLAN DIAGNÓSTICO --- */
y = sectionHeader(page, "PLAN DIAGNÓSTICO", ML, y, INNER, 13, { bold, regular });
const pdH = 40;
rect(page, ML, y - pdH, INNER, pdH);
y -= pdH + 4;
/* --- DIAGNÓSTICO AL INGRESO --- */
y = sectionHeader(page, "DIAGNÓSTICO AL INGRESO", ML, y, INNER, 13, { bold, regular });
const diH = 40;
rect(page, ML, y - diH, INNER, diH);
y -= diH + 4;
/* --- PLAN TERAPÉUTICO --- */
y = sectionHeader(page, "PLAN TERAPÈUTICO", ML, y, INNER, 13, { bold, regular });
const ptH = 9;
const ptColW = INNER / 2;
for (let i = 1; i <= 20; i++) {
const col = i <= 10 ? 0 : 1;
const row = i <= 10 ? i - 1 : i - 11;
const ptX = ML + col * ptColW;
const ptY = y - row * ptH - ptH;
rect(page, ptX, ptY, 10, ptH);
page.drawText(`${i}`, { x: ptX + 2, y: ptY + 2, size: 6.5, font: regular, color: NEGRO });
rect(page, ptX + 10, ptY, ptColW - 10, ptH);
}
// Firma
const firmaY = y - 10 * ptH - 20;
page.drawText("......................................", {
x: A4W - ML - 120, y: firmaY, size: 8, font: regular, color: NEGRO
});
}
/* ============================================================
MAIN ensambla todo y descarga
============================================================ */
async function generarPDF() {
const status = document.getElementById("status");
status.textContent = "Generando PDF...";
try {
const pdfDoc = await PDFLib.PDFDocument.create();
const regular = await pdfDoc.embedFont(PDFLib.StandardFonts.Helvetica);
const bold = await pdfDoc.embedFont(PDFLib.StandardFonts.HelveticaBold);
const fonts = { regular, bold };
await buildPage1(pdfDoc, bold, regular);
await buildPage2(pdfDoc, bold, regular);
await buildPage3(pdfDoc, bold, regular);
await buildPage4(pdfDoc, bold, regular);
await buildPage5(pdfDoc, bold, regular);
const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "HC_Ingreso_Santojanni.pdf";
a.click();
URL.revokeObjectURL(url);
status.textContent = "✅ PDF generado y descargado correctamente.";
} catch (e) {
console.error(e);
status.textContent = "❌ Error al generar el PDF. Ver consola para detalles.";
}
}
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sistema de Gestión Hospitalaria</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/okcomputer/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Administración Hospital",
"description": "Sistema de gestión y administración hospitalaria.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy all API requests to backend
location /api/ {
proxy_pass http://127.0.0.1:4000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
# Cache for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/rss text/javascript;
}
+10042
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"express": "^4.18.2",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"lucide-react": "^0.562.0",
"mongodb": "^7.5.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.23.9",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.3.2"
}
}
+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
let code = fs.readFileSync('src/App.tsx', 'utf8');
code = code.replace(/store\.getLaboratoriosByPaciente\(paciente\.id\)/g, 'store.getLaboratoriosByInternacion(internacion.id)');
code = code.replace(/store\.getGlucemiasByPaciente\(paciente\.id\)/g, 'store.getGlucemiasByInternacion(internacion.id)');
code = code.replace(/store\.getAcidosBaseByPaciente\(paciente\.id\)/g, 'store.getAcidosBaseByInternacion(internacion.id)');
code = code.replace(/store\.getCultivosByPaciente\(paciente\.id\)/g, 'store.getCultivosByInternacion(internacion.id)');
code = code.replace(/store\.estudiosComplementarios\.filter\(e => e\.pacienteId === paciente\.id\)/g, 'store.estudiosComplementarios.filter(e => e.internacionId === internacion.id)');
fs.writeFileSync('src/App.tsx', code);
+109
View File
@@ -0,0 +1,109 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
// Add computeSector function
const computeSectorCode = `
function computeSector(numero) {
if (!numero) return 'En Área';
const parts = String(numero).trim().split('-');
if (parts.length === 0) return 'En Área';
const salaStr = parts[0].trim();
const salaNum = parseInt(salaStr, 10);
if (isNaN(salaNum)) return 'En Área';
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) {
return 'Fuera de Área';
}
if (salaNum >= 400 && salaNum < 500) {
return 'Fuera de Área';
}
return 'En Área';
}
`;
// Find where to insert computeSector
code = code.replace("export function createCama(cama) {", computeSectorCode + "\nexport function createCama(cama) {");
// Update createCama
code = code.replace(
/export function createCama\(cama\) \{\s*const gId = cama.grupoId \|\| cama.areaId \|\| null;\s*db.prepare\('INSERT INTO camas \(id, numero, grupoId, areaId, tipo, estado, pacienteId, internacionId\) VALUES \(\?, \?, \?, \?, \?, \?, \?, \?\)'\)\.run\(cama.id, cama.numero, gId, gId, cama.tipo \|\| 'Estándar', cama.estado \|\| 'Disponible', cama.pacienteId \|\| null, cama.internacionId \|\| null\);\s*\}/,
`export function createCama(cama) {
const gId = cama.grupoId || cama.areaId || null;
const sector = computeSector(cama.numero);
db.prepare('INSERT INTO camas (id, numero, grupoId, areaId, tipo, estado, pacienteId, internacionId, sector) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(cama.id, cama.numero, gId, gId, cama.tipo || 'Estándar', cama.estado || 'Disponible', cama.pacienteId || null, cama.internacionId || null, sector);
}`
);
// Update updateCama
code = code.replace(
/export function updateCama\(id, updates\) \{[\s\S]*?if \(fields\.length > 0\) \{/,
`export function updateCama(id, updates) {
const fields = [];
const values = [];
if (updates.numero !== undefined) {
fields.push('numero = ?');
values.push(updates.numero);
fields.push('sector = ?');
values.push(computeSector(updates.numero));
}
if (updates.tipo !== undefined) {
fields.push('tipo = ?');
values.push(updates.tipo);
}
if (updates.grupoId !== undefined) {
fields.push('grupoId = ?');
values.push(updates.grupoId);
fields.push('areaId = ?');
values.push(updates.grupoId);
}
if (updates.estado !== undefined) {
fields.push('estado = ?');
values.push(updates.estado);
}
if (updates.pacienteId !== undefined) {
fields.push('pacienteId = ?');
values.push(updates.pacienteId);
}
if (updates.internacionId !== undefined) {
fields.push('internacionId = ?');
values.push(updates.internacionId);
}
if (fields.length > 0) {`
);
// Update table schema
code = code.replace(
/CREATE TABLE IF NOT EXISTS camas \(\s*id TEXT PRIMARY KEY,\s*numero TEXT NOT NULL,\s*grupoId TEXT,\s*areaId TEXT,\s*tipo TEXT DEFAULT 'Estándar',\s*estado TEXT DEFAULT 'Disponible',\s*pacienteId TEXT,\s*internacionId TEXT\s*\);/,
`CREATE TABLE IF NOT EXISTS camas (
id TEXT PRIMARY KEY,
numero TEXT NOT NULL,
grupoId TEXT,
areaId TEXT,
tipo TEXT DEFAULT 'Estándar',
estado TEXT DEFAULT 'Disponible',
pacienteId TEXT,
internacionId TEXT,
sector TEXT DEFAULT 'En Área'
);
try {
db.prepare("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'").run();
} catch(e) {}
try {
// Initialize existing beds with their sector
const rows = db.prepare('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"').all();
for (const row of rows) {
if (!row.numero) continue;
const salaStr = String(row.numero).trim().split('-')[0].trim();
const salaNum = parseInt(salaStr, 10);
let s = 'En Área';
if (!isNaN(salaNum)) {
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) s = 'Fuera de Área';
else if (salaNum >= 400 && salaNum < 500) s = 'Fuera de Área';
}
db.prepare('UPDATE camas SET sector = ? WHERE id = ?').run(s, row.id);
}
} catch(e) {}`
);
fs.writeFileSync('server/db.js', code);
+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/const __dirname = dirname\(fileURLToPath\(import\.meta\.url\)\);/,
`const currentDir = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url));`
);
code = code.replace(/__dirname/g, "currentDir");
fs.writeFileSync('server/db.js', code);
+9
View File
@@ -0,0 +1,9 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/const currentDir = typeof currentDir !== 'undefined' \? currentDir : dirname\(fileURLToPath\(import\.meta\.url\)\);/,
`const currentDir = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url));`
);
fs.writeFileSync('server/db.js', code);
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
let code = fs.readFileSync('server/index.js', 'utf8');
const computeSectorCode = `
function computeSector(numero) {
if (!numero) return 'En Área';
const parts = String(numero).trim().split('-');
if (parts.length === 0) return 'En Área';
const salaStr = parts[0].trim();
const salaNum = parseInt(salaStr, 10);
if (isNaN(salaNum)) return 'En Área';
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) return 'Fuera de Área';
if (salaNum >= 400 && salaNum < 500) return 'Fuera de Área';
return 'En Área';
}
`;
// Insert computeSector somewhere at the top
code = code.replace("const PORT = process.env.PORT || 3000;", computeSectorCode + "\nconst PORT = process.env.PORT || 3000;");
code = code.replace(
/const stmt = db\.prepare\('INSERT OR IGNORE INTO camas \(id, numero, grupoId, areaId, tipo, estado\) VALUES \(\?, \?, \?, \?, \?, \?\)'\);\s*for \(const c of state\.camas\) \{\s*const gId = c\.grupoId \|\| c\.areaId \|\| null;\s*stmt\.run\(c\.id, c\.numero, gId, gId, c\.tipo, c\.estado\);\s*\}/,
`const stmt = db.prepare('INSERT OR IGNORE INTO camas (id, numero, grupoId, areaId, tipo, estado, sector) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const c of state.camas) {
const gId = c.grupoId || c.areaId || null;
const sector = computeSector(c.numero);
stmt.run(c.id, c.numero, gId, gId, c.tipo, c.estado, sector);
}`
);
code = code.replace(
/const stmt = db\.prepare\('INSERT INTO camas \(id, numero, areaId, tipo, estado\) VALUES \(\?, \?, \?, \?, \?\)'\);\s*for \(const c of updates\.camas\) \{\s*stmt\.run\(c\.id, c\.numero, c\.areaId, c\.tipo, c\.estado\);\s*\}/,
`const stmt = db.prepare('INSERT INTO camas (id, numero, grupoId, areaId, tipo, estado, sector) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const c of updates.camas) {
const sector = computeSector(c.numero);
stmt.run(c.id, c.numero, c.grupoId || c.areaId || null, c.grupoId || c.areaId || null, c.tipo, c.estado, sector);
}`
);
fs.writeFileSync('server/index.js', code);
+31
View File
@@ -0,0 +1,31 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/ \);\n try \{\n db\.prepare\("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'"\)\.run\(\);\n \} catch\(e\) \{\}\n try \{\n \/\/ Initialize existing beds with their sector\n const rows = db\.prepare\('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"'\)\.all\(\);\n for \(const row of rows\) \{\n if \(\!row\.numero\) continue;\n const salaStr = String\(row\.numero\)\.trim\(\)\.split\('-'\)\[0\]\.trim\(\);\n const salaNum = parseInt\(salaStr, 10\);\n let s = 'En Área';\n if \(\!isNaN\(salaNum\)\) \{\n if \(salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0\) s = 'Fuera de Área';\n else if \(salaNum >= 400 && salaNum < 500\) s = 'Fuera de Área';\n \}\n db\.prepare\('UPDATE camas SET sector = \? WHERE id = \?'\)\.run\(s, row\.id\);\n \}\n \} catch\(e\) \{\}/,
` );`
);
code = code.replace(
/\/\/ Seed default 4 grupos if empty/,
`try { db.exec("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'"); } catch (_) {}
try {
// Initialize existing beds with their sector
const rows = db.prepare('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"').all();
for (const row of rows) {
if (!row.numero) continue;
const salaStr = String(row.numero).trim().split('-')[0].trim();
const salaNum = parseInt(salaStr, 10);
let s = 'En Área';
if (!isNaN(salaNum)) {
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) s = 'Fuera de Área';
else if (salaNum >= 400 && salaNum < 500) s = 'Fuera de Área';
}
db.prepare('UPDATE camas SET sector = ? WHERE id = ?').run(s, row.id);
}
} catch(e) {}
// Seed default 4 grupos if empty`
);
fs.writeFileSync('server/db.js', code);
+21
View File
@@ -0,0 +1,21 @@
const fs = require('fs');
let code = fs.readFileSync('src/hooks/useHospitalStore.ts', 'utf8');
code = code.replace(/getLaboratoriosByPaciente = useCallback\(\(pacienteId: string\)/g, 'getLaboratoriosByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(l => l\.pacienteId === pacienteId\)/g, '.filter(l => l.internacionId === internacionId)');
code = code.replace(/getGlucemiasByPaciente = useCallback\(\(pacienteId: string\)/g, 'getGlucemiasByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(g => g\.pacienteId === pacienteId\)/g, '.filter(g => g.internacionId === internacionId)');
code = code.replace(/getAcidosBaseByPaciente = useCallback\(\(pacienteId: string\)/g, 'getAcidosBaseByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(a => a\.pacienteId === pacienteId\)/g, '.filter(a => a.internacionId === internacionId)');
code = code.replace(/getCultivosByPaciente = useCallback\(\(pacienteId: string\)/g, 'getCultivosByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(c => c\.pacienteId === pacienteId\)/g, '.filter(c => c.internacionId === internacionId)');
code = code.replace(/getLaboratoriosByPaciente,/g, 'getLaboratoriosByInternacion,');
code = code.replace(/getGlucemiasByPaciente,/g, 'getGlucemiasByInternacion,');
code = code.replace(/getAcidosBaseByPaciente,/g, 'getAcidosBaseByInternacion,');
code = code.replace(/getCultivosByPaciente,/g, 'getCultivosByInternacion,');
fs.writeFileSync('src/hooks/useHospitalStore.ts', code);
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs');
let code = fs.readFileSync('src/hooks/useHospitalStore.ts', 'utf8');
code = code.replace(
/try \{\s*await apiCall\('PUT', `\/camas\/\$\{id\}`/,
`try {
if (datos.numero !== undefined) {
datos.sector = computeSector(datos.numero);
}
await apiCall('PUT', \`/camas/\${id}\``
);
fs.writeFileSync('src/hooks/useHospitalStore.ts', code);
+16
View File
@@ -0,0 +1,16 @@
const fs = require('fs');
let code = fs.readFileSync('src/lib/utils.ts', 'utf8');
code = code.replace(
/export function isCamaFueraDeGrupo\([\s\S]*?return false;\n\}/,
`export function isCamaFueraDeGrupo(
cama: { numero: string; grupoId?: string; areaId?: string; sector?: string },
gruposOrAreas?: { id: string; nombre: string }[]
): boolean {
if (cama.sector === 'Fuera de Área') return true;
if (cama.sector === 'En Área') return false;
return computeSector(cama.numero) === 'Fuera de Área';
}`
);
fs.writeFileSync('src/lib/utils.ts', code);
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
import express from 'express';
import path from 'path';
import { createServer as createViteServer } from 'vite';
import apiApp, { initDb } from './server/api-mongodb.js';
async function startServer() {
// Initialize MongoDB
try {
await initDb();
} catch (err) {
console.error('Failed to initialize MongoDB:', err);
if (process.env.NODE_ENV === 'production') {
process.exit(1);
} else {
console.warn('Continuing in development mode without database connection.');
}
}
const app = express();
const PORT = 3000;
const HOST = '0.0.0.0';
// Mount API routes
app.use(apiApp);
// Vite middleware for development or static serving for production
if (process.env.NODE_ENV !== 'production') {
const vite = await createViteServer({
server: {
middlewareMode: true,
hmr: false,
},
appType: 'spa',
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get('*', (_req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, HOST, () => {
console.log(`Hospital Server running on http://${HOST}:${PORT}`);
});
}
startServer();
+742
View File
@@ -0,0 +1,742 @@
import express from 'express';
import cors from 'cors';
import { initDb,
getUsuarioByDni, getUsuarioById, getAllUsuarios, createUsuario, updateUsuario, deleteUsuario, verifyPassword, hashPassword,
getAllPacientes, getPacienteById, createPaciente, updatePaciente, deletePaciente,
getAllAreas, createArea, updateArea, deleteArea,
getAllCamas, getCamaById, createCama, updateCama, deleteCama,
getAllInternaciones, getInternacionById, createInternacion, updateInternacion, deleteInternacion,
getAllEvoluciones, createEvolucion, updateEvolucion, deleteEvolucion,
getAllLaboratorios, createLaboratorio, updateLaboratorio, deleteLaboratorio,
getAllGlucemias, createGlucemia, updateGlucemia, deleteGlucemia,
getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase,
getAllCultivos, createCultivo, updateCultivo, deleteCultivo,
getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario,
getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta,
getAllAtb, createAtb, updateAtb, deleteAtb,
getAllIndicaciones, createIndicacion, updateIndicacion, deleteIndicacion,
getAllMovimientosIndicaciones, createMovimientoIndicacion,
getValue, setValue
} from './db-mongodb.js';
const app = express();
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json({ limit: '50mb' }));
// UUID generator
function generateUUID() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// ========== STATE ENDPOINT (initial load) ==========
app.get('/api/state', async (req, res) => {
try {
const areasList = await getAllAreas();
const usuariosList = await getAllUsuarios();
const state = {
pacientes: await getAllPacientes(),
areas: areasList,
grupos: areasList,
camas: await getAllCamas(),
internaciones: await getAllInternaciones(),
evoluciones: (await getAllEvoluciones()).map(e => ({
...e,
signosVitales: typeof e.signosVitales === 'string' ? JSON.parse(e.signosVitales) : e.signosVitales,
examenFisico: typeof e.examenFisico === 'string' ? JSON.parse(e.examenFisico) : e.examenFisico
})),
laboratorios: (await getAllLaboratorios()).map(l => ({ ...l, resultados: typeof l.resultados === 'string' ? JSON.parse(l.resultados) : l.resultados })),
glucemias: await getAllGlucemias(),
acidosBase: await getAllAcidosBase(),
cultivos: await getAllCultivos(),
estudiosComplementarios: await getAllEstudiosComplementarios(),
interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })),
atb: await getAllAtb(),
indicaciones: await getAllIndicaciones(),
movimientosIndicaciones: await getAllMovimientosIndicaciones(),
usuarios: usuariosList.map(({ passwordHash, ...u }) => u),
vistaActual: 'dashboard',
currentInternacionId: null
};
res.json(state);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'failed to read state' });
}
});
// ========== USUARIOS ==========
app.get('/api/usuarios', async (req, res) => {
try {
const usuarios = await getAllUsuarios();
const usuariosSinPassword = usuarios.map(({ passwordHash, ...u }) => u);
res.json(usuariosSinPassword);
} catch (err) {
res.status(500).json({ error: 'Error al obtener usuarios' });
}
});
app.post('/api/usuarios', async (req, res) => {
try {
const { apellido, nombre, dni, fechaNacimiento, email, rol, matriculaProfesional, password, areaId } = req.body;
if (!apellido || !nombre || !dni || !fechaNacimiento || !email || !rol || !password) {
return res.status(400).json({ error: 'Todos los campos obligatorios deben estar presentes' });
}
const existente = await getUsuarioByDni(dni);
if (existente) {
return res.status(400).json({ error: 'Ya existe un usuario con ese DNI' });
}
const passwordHash = hashPassword(password);
const usuario = {
id: generateUUID(),
apellido, nombre, dni, fechaNacimiento, email, rol,
matriculaProfesional: matriculaProfesional || null,
passwordHash,
areaId: areaId || null,
fechaCreacion: new Date().toISOString().split('T')[0]
};
await createUsuario(usuario);
res.json({ ...usuario, passwordHash: undefined });
} catch (err) {
res.status(500).json({ error: 'Error al crear usuario' });
}
});
app.put('/api/usuarios/:id', async (req, res) => {
try {
const { id } = req.params;
const { apellido, nombre, fechaNacimiento, email, rol, matriculaProfesional, areaId, password } = req.body;
const usuario = await getUsuarioById(id);
if (!usuario) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
const datos = {};
if (apellido !== undefined) datos.apellido = apellido;
if (nombre !== undefined) datos.nombre = nombre;
if (fechaNacimiento !== undefined) datos.fechaNacimiento = fechaNacimiento;
if (email !== undefined) datos.email = email;
if (rol !== undefined) datos.rol = rol;
if (matriculaProfesional !== undefined) datos.matriculaProfesional = matriculaProfesional;
if (areaId !== undefined) datos.areaId = areaId;
if (password) datos.passwordHash = hashPassword(password);
await updateUsuario(id, datos);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar usuario' });
}
});
app.delete('/api/usuarios/:id', async (req, res) => {
try {
await deleteUsuario(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar usuario' });
}
});
// ========== PACIENTES ==========
app.get('/api/pacientes', async (req, res) => {
try {
res.json(await getAllPacientes());
} catch (err) {
res.status(500).json({ error: 'Error al obtener pacientes' });
}
});
app.post('/api/pacientes', async (req, res) => {
try {
const paciente = { ...req.body, id: generateUUID() };
await createPaciente(paciente);
res.json(paciente);
} catch (err) {
res.status(500).json({ error: 'Error al crear paciente' });
}
});
app.put('/api/pacientes/:id', async (req, res) => {
try {
await updatePaciente(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar paciente' });
}
});
app.delete('/api/pacientes/:id', async (req, res) => {
try {
await deletePaciente(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar paciente' });
}
});
// ========== AREAS & GRUPOS ==========
app.get(['/api/areas', '/api/grupos'], async (req, res) => {
try {
res.json(await getAllAreas());
} catch (err) {
res.status(500).json({ error: 'Error al obtener áreas/grupos' });
}
});
app.post(['/api/areas', '/api/grupos'], async (req, res) => {
try {
const area = { ...req.body, id: req.body.id || generateUUID() };
await createArea(area);
res.json(area);
} catch (err) {
res.status(500).json({ error: 'Error al crear área/grupo' });
}
});
app.put(['/api/areas/:id', '/api/grupos/:id'], async (req, res) => {
try {
await updateArea(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar área/grupo' });
}
});
app.delete(['/api/areas/:id', '/api/grupos/:id'], async (req, res) => {
try {
await deleteArea(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar área/grupo' });
}
});
// ========== CAMAS ==========
app.get('/api/camas', async (req, res) => {
try {
res.json(await getAllCamas());
} catch (err) {
res.status(500).json({ error: 'Error al obtener camas' });
}
});
app.put('/api/camas/:id', async (req, res) => {
try {
await updateCama(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar cama' });
}
});
app.post('/api/camas', async (req, res) => {
try {
const cama = req.body;
if (!cama.id) {
cama.id = generateUUID();
}
await createCama(cama);
res.json(cama);
} catch (err) {
res.status(500).json({ error: 'Error al crear cama' });
}
});
app.delete('/api/camas/:id', async (req, res) => {
try {
await deleteCama(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar cama' });
}
});
// ========== INTERNACIONES ==========
app.get('/api/internaciones', async (req, res) => {
try {
res.json(await getAllInternaciones());
} catch (err) {
res.status(500).json({ error: 'Error al obtener internaciones' });
}
});
app.post('/api/internaciones', async (req, res) => {
try {
const internacion = { ...req.body, id: generateUUID(), activa: true };
await createInternacion(internacion);
res.json(internacion);
} catch (err) {
res.status(500).json({ error: 'Error al crear internacion' });
}
});
app.put('/api/internaciones/:id', async (req, res) => {
try {
await updateInternacion(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar internacion' });
}
});
app.delete('/api/internaciones/:id', async (req, res) => {
try {
await deleteInternacion(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar internacion' });
}
});
// ========== EVOLUCIONES ==========
app.get('/api/evoluciones', async (req, res) => {
try {
res.json(await getAllEvoluciones());
} catch (err) {
res.status(500).json({ error: 'Error al obtener evoluciones' });
}
});
app.post('/api/evoluciones', async (req, res) => {
try {
const evolucion = { ...req.body, id: generateUUID() };
await createEvolucion(evolucion);
res.json(evolucion);
} catch (err) {
res.status(500).json({ error: 'Error al crear evolucion' });
}
});
app.put('/api/evoluciones/:id', async (req, res) => {
try {
await updateEvolucion(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar evolucion' });
}
});
app.delete('/api/evoluciones/:id', async (req, res) => {
try {
await deleteEvolucion(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar evolucion' });
}
});
// ========== LABORATORIOS ==========
app.get('/api/laboratorios', async (req, res) => {
try {
res.json(await getAllLaboratorios());
} catch (err) {
res.status(500).json({ error: 'Error al obtener laboratorios' });
}
});
app.post('/api/laboratorios', async (req, res) => {
try {
const laboratorio = { ...req.body, id: generateUUID() };
await createLaboratorio(laboratorio);
res.json(laboratorio);
} catch (err) {
res.status(500).json({ error: 'Error al crear laboratorio' });
}
});
app.put('/api/laboratorios/:id', async (req, res) => {
try {
await updateLaboratorio(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar laboratorio' });
}
});
app.delete('/api/laboratorios/:id', async (req, res) => {
try {
await deleteLaboratorio(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar laboratorio' });
}
});
// ========== GLUCEMIAS ==========
app.get('/api/glucemias', async (req, res) => {
try {
res.json(await getAllGlucemias());
} catch (err) {
res.status(500).json({ error: 'Error al obtener glucemias' });
}
});
app.post('/api/glucemias', async (req, res) => {
try {
const glucemia = { ...req.body, id: generateUUID() };
await createGlucemia(glucemia);
res.json(glucemia);
} catch (err) {
res.status(500).json({ error: 'Error al crear glucemia' });
}
});
app.put('/api/glucemias/:id', async (req, res) => {
try {
await updateGlucemia(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar glucemia' });
}
});
app.delete('/api/glucemias/:id', async (req, res) => {
try {
await deleteGlucemia(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar glucemia' });
}
});
// ========== ACIDOS BASE ==========
app.get('/api/acid-os-base', async (req, res) => {
try {
res.json(await getAllAcidosBase());
} catch (err) {
res.status(500).json({ error: 'Error al obtener acidos base' });
}
});
app.post('/api/acid-os-base', async (req, res) => {
try {
const acido = { ...req.body, id: generateUUID() };
await createAcidoBase(acido);
res.json(acido);
} catch (err) {
res.status(500).json({ error: 'Error al crear acido base' });
}
});
app.put('/api/acid-os-base/:id', async (req, res) => {
try {
await updateAcidoBase(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar acido base' });
}
});
app.delete('/api/acid-os-base/:id', async (req, res) => {
try {
await deleteAcidoBase(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar acido base' });
}
});
// ========== CULTIVOS ==========
app.get('/api/cultivos', async (req, res) => {
try {
res.json(await getAllCultivos());
} catch (err) {
res.status(500).json({ error: 'Error al obtener cultivos' });
}
});
app.post('/api/cultivos', async (req, res) => {
try {
const cultivo = { ...req.body, id: generateUUID() };
await createCultivo(cultivo);
res.json(cultivo);
} catch (err) {
res.status(500).json({ error: 'Error al crear cultivo' });
}
});
app.put('/api/cultivos/:id', async (req, res) => {
try {
await updateCultivo(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar cultivo' });
}
});
app.delete('/api/cultivos/:id', async (req, res) => {
try {
await deleteCultivo(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar cultivo' });
}
});
// ========== ESTUDIOS COMPLEMENTARIOS ==========
app.get('/api/estudios-complementarios', async (req, res) => {
try {
res.json(await getAllEstudiosComplementarios());
} catch (err) {
res.status(500).json({ error: 'Error al obtener estudios' });
}
});
app.post('/api/estudios-complementarios', async (req, res) => {
try {
const estudio = { ...req.body, id: generateUUID() };
await createEstudioComplementario(estudio);
res.json(estudio);
} catch (err) {
res.status(500).json({ error: 'Error al crear estudio' });
}
});
app.put('/api/estudios-complementarios/:id', async (req, res) => {
try {
await updateEstudioComplementario(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar estudio' });
}
});
app.delete('/api/estudios-complementarios/:id', async (req, res) => {
try {
await deleteEstudioComplementario(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar estudio' });
}
});
// ========== INTERCONSULTAS ==========
app.get('/api/interconsultas', async (req, res) => {
try {
res.json(await getAllInterconsultas());
} catch (err) {
res.status(500).json({ error: 'Error al obtener interconsultas' });
}
});
app.post('/api/interconsultas', async (req, res) => {
try {
const interconsulta = { ...req.body, id: generateUUID() };
await createInterconsulta(interconsulta);
res.json(interconsulta);
} catch (err) {
res.status(500).json({ error: 'Error al crear interconsulta' });
}
});
app.put('/api/interconsultas/:id', async (req, res) => {
try {
await updateInterconsulta(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar interconsulta' });
}
});
app.delete('/api/interconsultas/:id', async (req, res) => {
try {
await deleteInterconsulta(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar interconsulta' });
}
});
// ========== ATB ==========
app.get('/api/atb', async (req, res) => {
try {
res.json(await getAllAtb());
} catch (err) {
res.status(500).json({ error: 'Error al obtener ATB' });
}
});
app.post('/api/atb', async (req, res) => {
try {
const atb = { ...req.body, id: generateUUID() };
await createAtb(atb);
res.json(atb);
} catch (err) {
res.status(500).json({ error: 'Error al crear ATB' });
}
});
app.put('/api/atb/:id', async (req, res) => {
try {
await updateAtb(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar ATB' });
}
});
app.delete('/api/atb/:id', async (req, res) => {
try {
await deleteAtb(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar ATB' });
}
});
// ========== INDICACIONES ==========
app.get('/api/indicaciones', async (req, res) => {
try {
res.json(await getAllIndicaciones());
} catch (err) {
res.status(500).json({ error: 'Error al obtener indicaciones' });
}
});
app.post('/api/indicaciones', async (req, res) => {
try {
const indicacion = { ...req.body, id: generateUUID() };
await createIndicacion(indicacion);
res.json(indicacion);
} catch (err) {
res.status(500).json({ error: 'Error al crear indicacion' });
}
});
app.put('/api/indicaciones/:id', async (req, res) => {
try {
await updateIndicacion(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar indicacion' });
}
});
app.delete('/api/indicaciones/:id', async (req, res) => {
try {
await deleteIndicacion(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar indicacion' });
}
});
// ========== MOVIMIENTOS INDICACIONES ==========
app.get('/api/movimientos-indicaciones', async (req, res) => {
try {
res.json(await getAllMovimientosIndicaciones());
} catch (err) {
res.status(500).json({ error: 'Error al obtener movimientos' });
}
});
app.post('/api/movimientos-indicaciones', async (req, res) => {
try {
const movimiento = { ...req.body, id: generateUUID() };
console.log('Creating movimiento:', movimiento);
await createMovimientoIndicacion(movimiento);
res.json(movimiento);
} catch (err) {
console.error('Error creating movimiento:', err);
res.status(500).json({ error: err.message || 'Error al crear movimiento' });
}
});
// ========== AUTH ==========
app.post('/api/auth/login', async (req, res) => {
try {
const { dni, password } = req.body;
if (!dni || !password) {
return res.status(400).json({ error: 'DNI y contraseña requeridos' });
}
const usuario = await getUsuarioByDni(dni);
if (!usuario) {
return res.status(401).json({ error: 'Usuario no encontrado' });
}
const validPassword = await verifyPassword(dni, password);
if (!validPassword) {
return res.status(401).json({ error: 'Contraseña incorrecta' });
}
const { passwordHash, ...userWithoutPassword } = usuario;
res.json(userWithoutPassword);
} catch (err) {
res.status(500).json({ error: 'Error en autenticación' });
}
});
app.post('/api/auth/change-password', async (req, res) => {
try {
const { dni, oldPassword, newPassword } = req.body;
if (!dni || !oldPassword || !newPassword) {
return res.status(400).json({ error: 'Todos los campos son requeridos' });
}
const validPassword = await verifyPassword(dni, oldPassword);
if (!validPassword) {
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
}
const newHash = hashPassword(newPassword);
const usuario = await getUsuarioByDni(dni);
if (usuario) {
await updateUsuario(usuario.id, { passwordHash: newHash });
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al cambiar contraseña' });
}
});
app.put('/api/auth/update-email', async (req, res) => {
try {
const { dni, newEmail } = req.body;
if (!dni || !newEmail) {
return res.status(400).json({ error: 'DNI y email son requeridos' });
}
const usuario = await getUsuarioByDni(dni);
if (usuario) {
await updateUsuario(usuario.id, { email: newEmail });
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar email' });
}
});
// Initialize DB and start server
const PORT = process.env.PORT || 4000;
const HOST = process.env.HOST || '0.0.0.0';
if (import.meta.url === `file://${process.argv[1]}`) {
initDb()
.then(() => {
app.listen(PORT, HOST, () => {
console.log(`Backend API running on http://${HOST}:${PORT}`);
});
})
.catch((err) => {
console.error('Failed to initialize MongoDB in standalone mode:', err);
process.exit(1);
});
}
export { initDb };
export default app;
+945
View File
@@ -0,0 +1,945 @@
import { MongoClient } from 'mongodb';
import bcrypt from 'bcryptjs';
const { compareSync, hashSync } = bcrypt;
export function generateUUID() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getMongoUri() {
const envUri = (process.env.MONGODB_URI || '').trim();
if (envUri.startsWith('mongodb://') || envUri.startsWith('mongodb+srv://')) {
return envUri;
}
return 'mongodb://127.0.0.1:27017';
}
const DB_NAME = process.env.DB_NAME || 'hospital';
let client;
let db = null;
// Helper to create default admin object from env or fallback
function createDefaultAdminUser() {
const adminDni = process.env.ADMIN_DNI || '12345678';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const adminNombre = process.env.ADMIN_NOMBRE || 'Sistema';
const adminApellido = process.env.ADMIN_APELLIDO || 'Administrador';
const adminEmail = process.env.ADMIN_EMAIL || 'admin@hospital.local';
return {
id: 'admin-default',
apellido: adminApellido,
nombre: adminNombre,
dni: adminDni,
fechaNacimiento: '1990-01-01',
email: adminEmail,
rol: 'admin',
passwordHash: hashSync(adminPassword, 10),
fechaCreacion: new Date().toISOString().split('T')[0]
};
}
// In-memory fallback store when DB is not connected
const memStore = {
usuarios: [createDefaultAdminUser()],
pacientes: [],
areas: [],
camas: [],
internaciones: [],
evoluciones: [],
laboratorios: [],
glucemias: [],
acidosbase: [],
cultivos: [],
estudiosComplementarios: [],
interconsultas: [],
atb: [],
indicaciones: [],
movimientos_indicaciones: [],
kv: {}
};
export async function initDb() {
try {
const uri = getMongoUri();
client = new MongoClient(uri, {
connectTimeoutMS: 5000,
serverSelectionTimeoutMS: 5000
});
await client.connect();
db = client.db(DB_NAME);
console.log(`Connected to MongoDB database: ${DB_NAME}`);
// Create unique index for users and patients DNI
await db.collection('usuarios').createIndex({ dni: 1 }, { unique: true });
await db.collection('pacientes').createIndex({ dni: 1 }, { unique: true });
await db.collection('areas').createIndex({ nombre: 1 }, { unique: true });
// Check if default admin exists in MongoDB in production
const adminDni = process.env.ADMIN_DNI || '12345678';
const adminExists = await db.collection('usuarios').findOne({ dni: adminDni });
if (!adminExists) {
const defaultAdmin = createDefaultAdminUser();
await db.collection('usuarios').insertOne(defaultAdmin);
console.log(`Default admin created/ensured in database: DNI ${adminDni}`);
}
} catch (error) {
console.error('Failed to initialize MongoDB (will use in-memory store):', error.message || error);
db = null;
}
}
// Helper to remove MongoDB's internal _id from returned objects to keep schema clean
function cleanDoc(doc) {
if (!doc) return null;
const { _id, ...rest } = doc;
return rest;
}
function cleanDocs(docs) {
if (!docs) return [];
return docs.map(cleanDoc);
}
// ========== USUARIOS ==========
export async function getUsuarioByDni(dni) {
const dniStr = String(dni).trim();
if (db) {
const user = await db.collection('usuarios').findOne({ dni: dniStr });
return cleanDoc(user);
}
return memStore.usuarios.find(u => String(u.dni) === dniStr) || null;
}
export async function getUsuarioById(id) {
if (db) {
const user = await db.collection('usuarios').findOne({ id });
return cleanDoc(user);
}
return memStore.usuarios.find(u => u.id === id) || null;
}
export async function getAllUsuarios() {
if (db) {
const users = await db.collection('usuarios').find().sort({ apellido: 1, nombre: 1 }).toArray();
return cleanDocs(users);
}
return [...memStore.usuarios];
}
export async function createUsuario(usuario) {
if (db) {
await db.collection('usuarios').insertOne(usuario);
} else {
memStore.usuarios.push(usuario);
}
}
export async function updateUsuario(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
updateDoc[key] = val;
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('usuarios').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.usuarios.findIndex(u => u.id === id);
if (idx !== -1) {
memStore.usuarios[idx] = { ...memStore.usuarios[idx], ...updateDoc };
}
}
}
export async function deleteUsuario(id) {
if (db) {
await db.collection('usuarios').deleteOne({ id });
} else {
memStore.usuarios = memStore.usuarios.filter(u => u.id !== id);
}
}
export async function verifyPassword(dni, password) {
const dniStr = String(dni).trim();
if (db) {
const user = await db.collection('usuarios').findOne({ dni: dniStr });
if (!user || !user.passwordHash) return false;
return compareSync(password, user.passwordHash);
}
const user = memStore.usuarios.find(u => String(u.dni) === dniStr);
if (!user || !user.passwordHash) return false;
return compareSync(password, user.passwordHash);
}
export function hashPassword(password) {
return hashSync(password, 10);
}
// ========== PACIENTES ==========
export async function getAllPacientes() {
if (db) {
const pacientes = await db.collection('pacientes').find().toArray();
return cleanDocs(pacientes);
}
return [...memStore.pacientes];
}
export async function getPacienteById(id) {
if (db) {
const paciente = await db.collection('pacientes').findOne({ id });
return cleanDoc(paciente);
}
return memStore.pacientes.find(p => p.id === id) || null;
}
export async function createPaciente(paciente) {
if (db) {
await db.collection('pacientes').insertOne(paciente);
} else {
memStore.pacientes.push(paciente);
}
}
export async function updatePaciente(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (key !== 'id' && val !== undefined) {
updateDoc[key] = val;
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('pacientes').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.pacientes.findIndex(p => p.id === id);
if (idx !== -1) {
memStore.pacientes[idx] = { ...memStore.pacientes[idx], ...updateDoc };
}
}
}
export async function deletePaciente(id) {
if (db) {
await db.collection('pacientes').deleteOne({ id });
} else {
memStore.pacientes = memStore.pacientes.filter(p => p.id !== id);
}
}
// ========== AREAS / GRUPOS ==========
export async function getAllAreas() {
if (db) {
const areas = await db.collection('areas').find().toArray();
return cleanDocs(areas);
}
return [...memStore.areas];
}
export async function createArea(area) {
if (db) {
await db.collection('areas').insertOne(area);
} else {
memStore.areas.push(area);
}
}
export async function updateArea(id, datos) {
if (db) {
if (datos.nombre !== undefined) {
await db.collection('areas').updateOne({ id }, { $set: { nombre: datos.nombre } });
}
} else {
const idx = memStore.areas.findIndex(a => a.id === id);
if (idx !== -1) {
memStore.areas[idx] = { ...memStore.areas[idx], ...datos };
}
}
}
export async function deleteArea(id) {
if (db) {
await db.collection('areas').deleteOne({ id });
} else {
memStore.areas = memStore.areas.filter(a => a.id !== id);
}
}
// ========== CAMAS ==========
export async function getAllCamas() {
if (db) {
const camas = await db.collection('camas').find().toArray();
return cleanDocs(camas);
}
return [...memStore.camas];
}
export async function getCamaById(id) {
if (db) {
const cama = await db.collection('camas').findOne({ id });
return cleanDoc(cama);
}
return memStore.camas.find(c => c.id === id) || null;
}
export async function createCama(cama) {
const id = cama.id || generateUUID();
const doc = {
id,
numero: cama.numero,
areaId: cama.areaId || cama.grupoId,
grupoId: cama.grupoId || cama.areaId,
tipo: cama.tipo || 'Estándar',
estado: cama.estado || 'Disponible',
pacienteId: cama.pacienteId || null,
internacionId: cama.internacionId || null
};
if (db) {
await db.collection('camas').insertOne(doc);
} else {
memStore.camas.push(doc);
}
}
export async function updateCama(id, updates) {
const updateDoc = {};
for (const [key, val] of Object.entries(updates)) {
if (val !== undefined) {
updateDoc[key] = val;
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('camas').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.camas.findIndex(c => c.id === id);
if (idx !== -1) {
memStore.camas[idx] = { ...memStore.camas[idx], ...updateDoc };
}
}
}
export async function deleteCama(id) {
if (db) {
await db.collection('camas').deleteOne({ id });
} else {
memStore.camas = memStore.camas.filter(c => c.id !== id);
}
}
// ========== INTERNACIONES ==========
export async function getAllInternaciones() {
if (db) {
const internaciones = await db.collection('internaciones').find().toArray();
return cleanDocs(internaciones).map(i => ({ ...i, activa: !!i.activa }));
}
return memStore.internaciones.map(i => ({ ...i, activa: !!i.activa }));
}
export async function getInternacionById(id) {
if (db) {
const internacion = await db.collection('internaciones').findOne({ id });
if (internacion) internacion.activa = !!internacion.activa;
return cleanDoc(internacion);
}
const internacion = memStore.internaciones.find(i => i.id === id);
if (!internacion) return null;
return { ...internacion, activa: !!internacion.activa };
}
export async function createInternacion(internacion) {
const doc = {
id: internacion.id,
pacienteId: internacion.pacienteId,
camaId: internacion.camaId || null,
areaId: internacion.areaId || internacion.grupoId || null,
grupoId: internacion.grupoId || internacion.areaId || null,
fechaIngresoHospital: internacion.fechaIngresoHospital || null,
fechaIngresoClinica: internacion.fechaIngresoClinica || null,
fechaEgreso: internacion.fechaEgreso || null,
diagnosticoIngreso: internacion.diagnosticoIngreso || null,
motivoConsulta: internacion.motivoConsulta || null,
enfermedadActual: internacion.enfermedadActual || null,
antecedentesEnfermedadActual: internacion.antecedentesEnfermedadActual || null,
diagnosticoEgreso: internacion.diagnosticoEgreso || null,
medicoIngresante: internacion.medicoIngresante || null,
motivoEgreso: internacion.motivoEgreso || null,
activa: internacion.activa ? 1 : 0,
apache: internacion.apache || null,
derivacion: internacion.derivacion || null
};
if (db) {
await db.collection('internaciones').insertOne(doc);
} else {
memStore.internaciones.push(doc);
}
}
export async function updateInternacion(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
if (key === 'activa') {
updateDoc.activa = val ? 1 : 0;
} else {
updateDoc[key] = val;
}
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('internaciones').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.internaciones.findIndex(i => i.id === id);
if (idx !== -1) {
memStore.internaciones[idx] = { ...memStore.internaciones[idx], ...updateDoc };
}
}
}
export async function deleteInternacion(id) {
if (db) {
await db.collection('internaciones').deleteOne({ id });
} else {
memStore.internaciones = memStore.internaciones.filter(i => i.id !== id);
}
}
// ========== EVOLUCIONES ==========
export async function getAllEvoluciones() {
if (db) {
const evoluciones = await db.collection('evoluciones').find().toArray();
return cleanDocs(evoluciones);
}
return [...memStore.evoluciones];
}
export async function createEvolucion(evolucion) {
const doc = {
id: evolucion.id,
internacionId: evolucion.internacionId,
fecha: evolucion.fecha,
hora: evolucion.hora || '',
medico: evolucion.medico || '',
signosVitales: typeof evolucion.signosVitales === 'string' ? evolucion.signosVitales : JSON.stringify(evolucion.signosVitales || {}),
examenFisico: typeof evolucion.examenFisico === 'string' ? evolucion.examenFisico : JSON.stringify(evolucion.examenFisico || {}),
novedades: evolucion.novedades || '',
comentarios: evolucion.comentarios || evolucion.comentario || '',
pendientes: evolucion.pendientes || ''
};
if (db) {
await db.collection('evoluciones').insertOne(doc);
} else {
memStore.evoluciones.push(doc);
}
}
export async function updateEvolucion(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
if (key === 'comentario') {
updateDoc.comentarios = val;
} else if (key === 'signosVitales' || key === 'examenFisico') {
updateDoc[key] = typeof val === 'string' ? val : JSON.stringify(val);
} else {
updateDoc[key] = val;
}
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('evoluciones').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.evoluciones.findIndex(e => e.id === id);
if (idx !== -1) {
memStore.evoluciones[idx] = { ...memStore.evoluciones[idx], ...updateDoc };
}
}
}
export async function deleteEvolucion(id) {
if (db) {
await db.collection('evoluciones').deleteOne({ id });
} else {
memStore.evoluciones = memStore.evoluciones.filter(e => e.id !== id);
}
}
// ========== LABORATORIOS ==========
export async function getAllLaboratorios() {
if (db) {
const laboratorios = await db.collection('laboratorios').find().toArray();
return cleanDocs(laboratorios);
}
return [...memStore.laboratorios];
}
export async function createLaboratorio(laboratorio) {
const doc = {
id: laboratorio.id,
pacienteId: laboratorio.pacienteId,
internacionId: laboratorio.internacionId || null,
fecha: laboratorio.fecha,
hora: laboratorio.hora || null,
tipo: laboratorio.tipo,
resultados: typeof laboratorio.resultados === 'string' ? laboratorio.resultados : JSON.stringify(laboratorio.resultados || {}),
observaciones: laboratorio.observaciones || null,
medicoSolicitante: laboratorio.medicoSolicitante || null
};
if (db) {
await db.collection('laboratorios').insertOne(doc);
} else {
memStore.laboratorios.push(doc);
}
}
export async function updateLaboratorio(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
if (key === 'resultados') {
updateDoc.resultados = typeof val === 'string' ? val : JSON.stringify(val);
} else {
updateDoc[key] = val;
}
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('laboratorios').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.laboratorios.findIndex(l => l.id === id);
if (idx !== -1) {
memStore.laboratorios[idx] = { ...memStore.laboratorios[idx], ...updateDoc };
}
}
}
export async function deleteLaboratorio(id) {
if (db) {
await db.collection('laboratorios').deleteOne({ id });
} else {
memStore.laboratorios = memStore.laboratorios.filter(l => l.id !== id);
}
}
// ========== GLUCEMIAS ==========
export async function getAllGlucemias() {
if (db) {
const glucemias = await db.collection('glucemias').find().toArray();
return cleanDocs(glucemias);
}
return [...memStore.glucemias];
}
export async function createGlucemia(glucemia) {
if (db) {
await db.collection('glucemias').insertOne(glucemia);
} else {
memStore.glucemias.push(glucemia);
}
}
export async function updateGlucemia(id, datos) {
if (db) {
await db.collection('glucemias').updateOne({ id }, { $set: datos });
} else {
const idx = memStore.glucemias.findIndex(g => g.id === id);
if (idx !== -1) {
memStore.glucemias[idx] = { ...memStore.glucemias[idx], ...datos };
}
}
}
export async function deleteGlucemia(id) {
if (db) {
await db.collection('glucemias').deleteOne({ id });
} else {
memStore.glucemias = memStore.glucemias.filter(g => g.id !== id);
}
}
// ========== ACIDOS BASE ==========
export async function getAllAcidosBase() {
if (db) {
const acidos = await db.collection('acidosbase').find().toArray();
return cleanDocs(acidos);
}
return [...memStore.acidosbase];
}
export async function createAcidoBase(acido) {
const doc = {
id: acido.id,
pacienteId: acido.pacienteId,
internacionId: acido.internacionId || null,
fecha: acido.fecha,
hora: acido.hora || null,
ph: acido.ph !== undefined ? Number(acido.ph) : null,
pco2: acido.pco2 !== undefined ? Number(acido.pco2) : null,
po2: acido.po2 !== undefined ? Number(acido.po2) : null,
hco3: acido.hco3 !== undefined ? Number(acido.hco3) : null,
be: acido.be !== undefined ? Number(acido.be) : null,
sato2: acido.sato2 !== undefined ? Number(acido.sato2) : null,
lactato: acido.lactato !== undefined ? Number(acido.lactato) : null,
interpretacion: acido.interpretacion || null,
fio2: acido.fio2 !== undefined ? Number(acido.fio2) : null
};
if (db) {
await db.collection('acidosbase').insertOne(doc);
} else {
memStore.acidosbase.push(doc);
}
}
export async function updateAcidoBase(id, datos) {
if (db) {
await db.collection('acidosbase').updateOne({ id }, { $set: datos });
} else {
const idx = memStore.acidosbase.findIndex(a => a.id === id);
if (idx !== -1) {
memStore.acidosbase[idx] = { ...memStore.acidosbase[idx], ...datos };
}
}
}
export async function deleteAcidoBase(id) {
if (db) {
await db.collection('acidosbase').deleteOne({ id });
} else {
memStore.acidosbase = memStore.acidosbase.filter(a => a.id !== id);
}
}
// ========== CULTIVOS ==========
export async function getAllCultivos() {
if (db) {
const cultivos = await db.collection('cultivos').find().toArray();
return cleanDocs(cultivos);
}
return [...memStore.cultivos];
}
export async function createCultivo(cultivo) {
const doc = {
id: cultivo.id,
pacienteId: cultivo.pacienteId,
internacionId: cultivo.internacionId,
fechaToma: cultivo.fechaToma || null,
protocolo: cultivo.protocolo || null,
fechaResultado: cultivo.fechaResultado || null,
tipoMuestra: cultivo.tipoMuestra || null,
germen: cultivo.germen || null,
sensible: cultivo.sensible || null,
resistente: cultivo.resistente || null,
estado: cultivo.estado || 'NAF/Pendiente',
observaciones: cultivo.observaciones || null
};
if (db) {
await db.collection('cultivos').insertOne(doc);
} else {
memStore.cultivos.push(doc);
}
}
export async function updateCultivo(id, datos) {
if (db) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
updateDoc[key] = val;
}
}
if (Object.keys(updateDoc).length > 0) {
await db.collection('cultivos').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.cultivos.findIndex(c => c.id === id);
if (idx !== -1) {
memStore.cultivos[idx] = { ...memStore.cultivos[idx], ...datos };
}
}
}
export async function deleteCultivo(id) {
if (db) {
await db.collection('cultivos').deleteOne({ id });
} else {
memStore.cultivos = memStore.cultivos.filter(c => c.id !== id);
}
}
// ========== ESTUDIOS COMPLEMENTARIOS ==========
export async function getAllEstudiosComplementarios() {
if (db) {
const estudios = await db.collection('estudiosComplementarios').find().toArray();
return cleanDocs(estudios);
}
return [...memStore.estudiosComplementarios];
}
export async function createEstudioComplementario(estudio) {
const doc = {
id: estudio.id,
pacienteId: estudio.pacienteId,
internacionId: estudio.internacionId || null,
fecha: estudio.fecha || null,
tipo: estudio.tipo || null,
resultado: estudio.resultado || null
};
if (db) {
await db.collection('estudiosComplementarios').insertOne(doc);
} else {
memStore.estudiosComplementarios.push(doc);
}
}
export async function updateEstudioComplementario(id, datos) {
if (db) {
await db.collection('estudiosComplementarios').updateOne({ id }, { $set: datos });
} else {
const idx = memStore.estudiosComplementarios.findIndex(e => e.id === id);
if (idx !== -1) {
memStore.estudiosComplementarios[idx] = { ...memStore.estudiosComplementarios[idx], ...datos };
}
}
}
export async function deleteEstudioComplementario(id) {
if (db) {
await db.collection('estudiosComplementarios').deleteOne({ id });
} else {
memStore.estudiosComplementarios = memStore.estudiosComplementarios.filter(e => e.id !== id);
}
}
// ========== INTERCONSULTAS ==========
export async function getAllInterconsultas() {
if (db) {
const interconsultas = await db.collection('interconsultas').find().toArray();
return cleanDocs(interconsultas).map(ic => ({ ...ic, realizada: !!ic.realizada }));
}
return memStore.interconsultas.map(ic => ({ ...ic, realizada: !!ic.realizada }));
}
export async function createInterconsulta(ic) {
const doc = {
id: ic.id,
pacienteId: ic.pacienteId,
internacionId: ic.internacionId,
fecha: ic.fecha || null,
servicioInterconsultado: ic.servicioInterconsultado,
motivo: ic.motivo || null,
respuestaInterconsulta: ic.respuestaInterconsulta || null,
respuestaFecha: ic.respuestaFecha || null
};
if (db) {
await db.collection('interconsultas').insertOne(doc);
} else {
memStore.interconsultas.push(doc);
}
}
export async function updateInterconsulta(id, datos) {
if (db) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
updateDoc[key] = val;
}
}
if (Object.keys(updateDoc).length > 0) {
await db.collection('interconsultas').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.interconsultas.findIndex(i => i.id === id);
if (idx !== -1) {
memStore.interconsultas[idx] = { ...memStore.interconsultas[idx], ...datos };
}
}
}
export async function deleteInterconsulta(id) {
if (db) {
await db.collection('interconsultas').deleteOne({ id });
} else {
memStore.interconsultas = memStore.interconsultas.filter(i => i.id !== id);
}
}
// ========== ATB ==========
export async function getAllAtb() {
if (db) {
const atb = await db.collection('atb').find().toArray();
return cleanDocs(atb);
}
return [...memStore.atb];
}
export async function createAtb(atb) {
const doc = {
id: atb.id,
pacienteId: atb.pacienteId,
internacionId: atb.internacionId,
antibiotico: atb.antibiotico,
fechaInicio: atb.fechaInicio,
fechaFinalizacion: atb.fechaFinalizacion || null
};
if (db) {
await db.collection('atb').insertOne(doc);
} else {
memStore.atb.push(doc);
}
}
export async function updateAtb(id, datos) {
if (db) {
await db.collection('atb').updateOne({ id }, { $set: datos });
} else {
const idx = memStore.atb.findIndex(a => a.id === id);
if (idx !== -1) {
memStore.atb[idx] = { ...memStore.atb[idx], ...datos };
}
}
}
export async function deleteAtb(id) {
if (db) {
await db.collection('atb').deleteOne({ id });
} else {
memStore.atb = memStore.atb.filter(a => a.id !== id);
}
}
// ========== INDICACIONES ==========
export async function getAllIndicaciones() {
if (db) {
const indicaciones = await db.collection('indicaciones').find().toArray();
return cleanDocs(indicaciones);
}
return [...memStore.indicaciones];
}
export async function createIndicacion(ind) {
const doc = {
id: ind.id,
internacionId: ind.internacionId,
tipo: ind.tipo,
droga: ind.droga || null,
dosis: ind.dosis || null,
frecuenciaHoras: ind.frecuenciaHoras || null,
via: ind.via || null,
tipoPlan: ind.tipoPlan || null,
tipoPlan2: ind.tipoPlan2 || null,
cantidadMl: ind.cantidadMl || null,
cantidadMl2: ind.cantidadMl2 || null,
tiempoHoras: ind.tiempoHoras || null,
estado: ind.estado || 'Activa',
medicoCrea: ind.medicoCrea || null,
fechaCrea: ind.fechaCrea || null,
tipoInsulina: ind.tipoInsulina || null,
unidadesDesayuno: ind.unidadesDesayuno || null,
unidadesAlmuerzo: ind.unidadesAlmuerzo || null,
unidadesNoche: ind.unidadesNoche || null,
indicacionNoFco: ind.indicacionNoFco || null
};
if (db) {
await db.collection('indicaciones').insertOne(doc);
} else {
memStore.indicaciones.push(doc);
}
}
export async function updateIndicacion(id, datos) {
const updateDoc = {};
for (const [key, val] of Object.entries(datos)) {
if (val !== undefined) {
updateDoc[key] = val;
}
}
if (db) {
if (Object.keys(updateDoc).length > 0) {
await db.collection('indicaciones').updateOne({ id }, { $set: updateDoc });
}
} else {
const idx = memStore.indicaciones.findIndex(i => i.id === id);
if (idx !== -1) {
memStore.indicaciones[idx] = { ...memStore.indicaciones[idx], ...updateDoc };
}
}
}
export async function deleteIndicacion(id) {
if (db) {
await db.collection('indicaciones').deleteOne({ id });
} else {
memStore.indicaciones = memStore.indicaciones.filter(i => i.id !== id);
}
}
// ========== MOVIMIENTOS INDICACIONES ==========
export async function getAllMovimientosIndicaciones() {
if (db) {
const movimientos = await db.collection('movimientos_indicaciones').find().toArray();
return cleanDocs(movimientos);
}
return [...memStore.movimientos_indicaciones];
}
export async function createMovimientoIndicacion(mov) {
const doc = {
id: mov.id,
indicacionId: mov.indicacionId,
internacionId: mov.internacionId,
tipo: mov.tipo,
fecha: mov.fecha,
profesional: mov.profesional,
indicacionPrevia: mov.indicacionPrevia || null,
indicacionNueva: mov.indicacionNueva || null
};
if (db) {
await db.collection('movimientos_indicaciones').insertOne(doc);
} else {
memStore.movimientos_indicaciones.push(doc);
}
}
// ========== KV STORE ==========
export async function getValue(key) {
if (db) {
const doc = await db.collection('kv').findOne({ key });
return doc?.value ?? null;
}
return memStore.kv[key] ?? null;
}
export async function setValue(key, value) {
if (db) {
await db.collection('kv').updateOne(
{ key },
{ $set: { key, value } },
{ upsert: true }
);
} else {
memStore.kv[key] = value;
}
}
+1220
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "hospital-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.5",
"mongodb": "^6.8.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
> my-app@0.0.0 dev
> tsx server.ts
View File
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+277
View File
@@ -0,0 +1,277 @@
import { useHospitalStore } from '@/hooks/useHospitalStore';
import { Layout } from '@/sections/Layout';
import { toast } from 'sonner';
import { Dashboard } from '@/sections/Dashboard';
import { MapaCamas } from '@/sections/MapaCamas';
import { Pacientes } from '@/sections/Pacientes';
import { Internaciones } from '@/sections/Internaciones';
import { Evoluciones } from '@/sections/Evoluciones';
import { AcidoBaseSection } from '@/sections/AcidoBase';
import { Cultivos } from '@/sections/Cultivos';
import { HistoriaClinica } from '@/sections/HistoriaClinica';
import { NuevoIngreso } from '@/sections/NuevoIngreso';
import { EditIngreso } from '@/sections/EditIngreso';
import { Login } from '@/sections/Login';
import { GestionUsuarios } from '@/sections/GestionUsuarios';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from '@/components/ui/sonner';
import type { Indicacion, MovimientoIndicacion } from '@/types';
function AppContent() {
const store = useHospitalStore();
if (!store.isLoaded) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spinner className="h-8 w-8" />
</div>
);
}
if (!store.isAuthenticated) {
return <Login />;
}
const renderVista = () => {
switch (store.vistaActual) {
case 'dashboard':
return (
<Dashboard
estadisticas={store.getEstadisticas()}
internaciones={store.internaciones}
cultivos={store.cultivos}
onCambiarVista={store.setVista}
getPacienteById={store.getPacienteById}
/>
);
case 'camas':
return (
<MapaCamas
camas={store.camas}
grupos={store.grupos}
pacientes={store.pacientes}
internaciones={store.internaciones}
onActualizarCama={store.actualizarCama}
onAgregarCama={store.agregarCama}
onEliminarCama={store.eliminarCama}
onIniciarInternacion={store.iniciarInternacion}
getPacienteById={store.getPacienteById}
getInternacionById={store.getInternacionById}
onAgregarGrupo={store.agregarGrupo}
onActualizarGrupo={store.actualizarGrupo}
onEliminarGrupo={store.eliminarGrupo}
/>
);
case 'pacientes':
return (
<Pacientes
pacientes={store.pacientes}
internaciones={store.internaciones}
onAgregar={store.agregarPaciente}
onActualizar={store.actualizarPaciente}
onEliminar={store.eliminarPaciente}
/>
);
case 'internaciones':
return (
<Internaciones
internaciones={store.internaciones}
pacientes={store.pacientes}
camas={store.camas}
grupos={store.grupos}
evoluciones={store.evoluciones}
laboratorios={store.laboratorios}
cultivos={store.cultivos}
onIniciarInternacion={store.iniciarInternacion}
onFinalizarInternacion={store.finalizarInternacion}
onEliminarInternacion={store.eliminarInternacion}
getPacienteById={store.getPacienteById}
getCamaById={store.getCamaById}
onVerHC={(id) => { store.setCurrentInternacion(id); store.setVista('historiaclinica'); }}
onNuevoIngreso={() => store.setVista('nuevoingreso')}
/>
);
case 'evoluciones':
return (
<Evoluciones
evoluciones={store.evoluciones}
internaciones={store.internaciones}
pacientes={store.pacientes}
camas={store.camas}
getCamaById={store.getCamaById}
onAgregarEvolucion={store.agregarEvolucion}
onActualizarEvolucion={store.actualizarEvolucion}
onEliminarEvolucion={store.eliminarEvolucion}
getPacienteById={store.getPacienteById}
getInternacionActivaByPaciente={store.getInternacionActivaByPaciente}
/>
);
case 'acidobase':
return (
<AcidoBaseSection
acidosBase={store.acidosBase}
pacientes={store.pacientes}
onAgregarAcidoBase={store.agregarAcidoBase}
onEliminarAcidoBase={store.eliminarAcidoBase}
getPacienteById={store.getPacienteById}
/>
);
case 'cultivos':
return (
<Cultivos
cultivos={store.cultivos}
pacientes={store.pacientes}
internaciones={store.internaciones}
camas={store.camas}
getPacienteById={store.getPacienteById}
getInternacionActivaByPaciente={store.getInternacionActivaByPaciente}
canEdit={false}
/>
);
case 'historiaclinica': {
const internacionId = store.currentInternacionId || '';
const internacion = store.getInternacionById(internacionId);
const paciente = internacion ? store.getPacienteById(internacion.pacienteId) : undefined;
if (!internacion || !paciente) {
return (
<div className="p-4 text-center">
<p className="text-gray-500 dark:text-gray-400">Internación no encontrada (id: {internacionId})</p>
<Button onClick={() => store.setVista('internaciones')}>Volver a Internaciones</Button>
</div>
);
}
return (
<HistoriaClinica
internacion={internacion}
paciente={paciente}
cama={store.getCamaById(internacion.camaId)}
allCamas={store.camas}
evoluciones={store.getEvolucionesByInternacion(internacion.id)}
laboratorios={store.getLaboratoriosByInternacion(internacion.id)}
glucemias={store.getGlucemiasByInternacion(internacion.id)}
acidosBase={store.getAcidosBaseByInternacion(internacion.id)}
cultivos={store.getCultivosByInternacion(internacion.id)}
estudiosComplementarios={store.estudiosComplementarios.filter(e => e.internacionId === internacion.id)}
interconsultas={(store.interconsultas || []).filter(ic => ic.internacionId === internacion.id)}
atb={(store.atb || []).filter(a => a.internacionId === internacion.id)}
onAgregarEvolucion={store.agregarEvolucion}
onActualizarEvolucion={store.actualizarEvolucion}
onEliminarEvolucion={store.eliminarEvolucion}
onAgregarLaboratorio={(l) => store.agregarLaboratorio({ ...l, internacionId: internacion.id })}
onActualizarLaboratorio={store.actualizarLaboratorio}
onEliminarLaboratorio={store.eliminarLaboratorio}
onAgregarGlucemia={(g) => store.agregarGlucemia({ ...g, internacionId: internacion.id })}
onActualizarGlucemia={store.actualizarGlucemia}
onEliminarGlucemia={store.eliminarGlucemia}
onAgregarAcidoBase={(a) => store.agregarAcidoBase({ ...a, internacionId: internacion.id })}
onActualizarAcidoBase={store.actualizarAcidoBase}
onEliminarAcidoBase={store.eliminarAcidoBase}
onAgregarCultivo={(c) => store.agregarCultivo({ ...c, internacionId: internacion.id })}
onActualizarCultivo={store.actualizarCultivo}
onEliminarCultivo={store.eliminarCultivo}
onAgregarEstudioComplementario={(e) => store.agregarEstudioComplementario({ ...e, internacionId: internacion.id })}
onActualizarEstudioComplementario={store.actualizarEstudioComplementario}
onEliminarEstudioComplementario={store.eliminarEstudioComplementario}
onAgregarInterconsulta={(ic) => store.agregarInterconsulta({ ...ic, internacionId: internacion.id })}
onActualizarInterconsulta={store.actualizarInterconsulta}
onEliminarInterconsulta={store.eliminarInterconsulta}
onAgregarATB={(atb) => store.agregarATB({ ...atb, internacionId: internacion.id })}
onActualizarATB={store.actualizarATB}
onEliminarATB={store.eliminarATB}
indicadores={(Array.isArray(store.indicaciones) ? store.indicaciones : []).filter((i: Indicacion) => i.internacionId === internacion.id)}
movimientos={store.getMovimientosByInternacion ? store.getMovimientosByInternacion(internacion.id) : []}
onAgregarIndicacion={(i) => store.agregarIndicacion({ ...i, internacionId: internacion.id })}
onActualizarIndicacion={store.actualizarIndicacion}
onEliminarIndicacion={store.eliminarIndicacion}
onAgregarMovimiento={(m: MovimientoIndicacion) => store.agregarMovimientoIndicacion ? store.agregarMovimientoIndicacion({ ...m, internacionId: internacion.id }) : null}
onActualizarInternacion={store.actualizarInternacion}
onActualizarCama={store.actualizarCama}
onVolver={() => store.setVista('internaciones')}
onEditarIngreso={() => {
if (store.canEditIngreso(internacion.id)) {
store.setVista('editaringreso');
} else {
toast.error(`Solo el usuario que creó esta internación (${internacion.medicoIngresante}) puede modificar el ingreso.`);
}
}}
canEdit={store.canEditInternacion(internacion.id)}
/>
);
}
case 'nuevoingreso':
return (
<NuevoIngreso
pacientes={store.pacientes}
camas={store.camas}
grupos={store.grupos}
onIniciarInternacion={store.iniciarInternacion}
onAgregarCama={store.agregarCama}
onVolver={() => store.setVista('internaciones')}
getGrupoName={(grupoId) => store.grupos.find(a => a.id === grupoId)?.nombre || ''}
internaciones={store.internaciones}
/>
);
case 'editaringreso': {
const internacionId = store.currentInternacionId || '';
const internacion = store.getInternacionById(internacionId);
const paciente = internacion ? store.getPacienteById(internacion.pacienteId) : undefined;
if (!internacion || !paciente) {
return (
<div className="p-4 text-center">
<p className="text-gray-500 dark:text-gray-400">Internación no encontrada (id: {internacionId})</p>
<Button onClick={() => store.setVista('internaciones')}>Volver a Internaciones</Button>
</div>
);
}
return (
<EditIngreso
internacion={internacion}
paciente={paciente}
cama={store.getCamaById(internacion.camaId)}
pacientes={store.pacientes}
camas={store.camas}
grupos={store.grupos}
onActualizarInternacion={store.actualizarInternacion}
onAgregarCama={store.agregarCama}
onVolver={() => store.setVista('historiaclinica')}
getGrupoName={(grupoId) => store.grupos.find(a => a.id === grupoId)?.nombre || ''}
/>
);
}
case 'usuarios':
if (store.currentUser?.rol !== 'admin') {
return (
<div className="p-8 text-center text-red-500 font-semibold">
<p>No tiene permisos para acceder a esta sección.</p>
<Button className="mt-4" onClick={() => store.setVista('dashboard')}>Volver al Dashboard</Button>
</div>
);
}
return (
<GestionUsuarios />
);
default:
return null;
}
};
return (
<Layout vistaActual={store.vistaActual} onCambiarVista={store.setVista} currentUser={store.currentUser} onLogout={store.logout}>
{renderVista()}
</Layout>
);
}
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<AppContent />
<Toaster position="top-right" />
</ThemeProvider>
);
}
export default App;
+74
View File
@@ -0,0 +1,74 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "light",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+47
View File
@@ -0,0 +1,47 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+84
View File
@@ -0,0 +1,84 @@
/* eslint-disable react-refresh/only-export-components */
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+63
View File
@@ -0,0 +1,63 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+220
View File
@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+239
View File
@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+31
View File
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+182
View File
@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+141
View File
@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+255
View File
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+168
View File
@@ -0,0 +1,168 @@
"use client"
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+274
View File
@@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+169
View File
@@ -0,0 +1,169 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+188
View File
@@ -0,0 +1,188 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+137
View File
@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+725
View File
@@ -0,0 +1,725 @@
"use client"
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = "70%";
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 640)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
const effectivePosition = isMobile ? "bottom-center" : (props.position || "top-right")
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position={effectivePosition}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-white dark:group-[.toaster]:bg-slate-900 group-[.toaster]:text-slate-900 dark:group-[.toaster]:text-slate-100 group-[.toaster]:border-slate-300 dark:group-[.toaster]:border-slate-700 group-[.toaster]:shadow-2xl opacity-100 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-700 shadow-2xl font-medium rounded-lg p-4",
description: "group-[.toast]:text-slate-600 dark:group-[.toast]:text-slate-400",
actionButton:
"group-[.toast]:bg-blue-600 group-[.toast]:text-white font-semibold",
cancelButton:
"group-[.toast]:bg-slate-200 group-[.toast]:text-slate-800 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-200",
},
}}
icons={{
success: <CircleCheckIcon className="size-5 text-emerald-600 dark:text-emerald-400" />,
info: <InfoIcon className="size-5 text-blue-600 dark:text-blue-400" />,
warning: <TriangleAlertIcon className="size-5 text-amber-600 dark:text-amber-400" />,
error: <OctagonXIcon className="size-5 text-red-600 dark:text-red-400" />,
loading: <Loader2Icon className="size-5 animate-spin text-blue-600 dark:text-blue-400" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+81
View File
@@ -0,0 +1,81 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+46
View File
@@ -0,0 +1,46 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.625rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 222.2 47.4% 11.2%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71% 4%;
--foreground: 210 40% 98%;
--card: 222 47% 10%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 10%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 20%;
--input: 217.2 32.6% 20%;
--ring: 217.2 91.2% 59.8%;
--sidebar-background: 224 71% 4%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 20%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground transition-colors duration-200;
}
textarea, input, select {
@apply bg-background text-foreground border-input placeholder:text-muted-foreground;
}
}
/* Toast styling for mobile margin and 100% opacity */
[data-sonner-toaster] {
z-index: 99999 !important;
}
[data-sonner-toast] {
background-color: #ffffff !important;
color: #0f172a !important;
opacity: 1 !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.25) !important;
border: 1px solid #cbd5e1 !important;
}
.dark [data-sonner-toast] {
background-color: #0f172a !important;
color: #f8fafc !important;
border-color: #334155 !important;
}
@media (max-width: 639px) {
[data-sonner-toaster] {
bottom: 20px !important;
top: auto !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: calc(100% - 32px) !important;
max-width: 420px !important;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { Usuario } from '@/types'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDateDDMMYYYY(dateString: string): string {
if (!dateString) return '';
const parts = dateString.split('-');
if (parts.length !== 3) return dateString;
const [year, month, day] = parts;
return `${day}-${month}-${year}`;
}
export function getNombreProfesional(user?: Usuario | null): string {
if (!user) return 'Admin';
if (user.rol === 'admin') return 'Admin';
const apellido = user.apellido?.trim() || '';
const nombre = user.nombre?.trim() || '';
if (apellido && nombre) return `${apellido}, ${nombre}`;
if (apellido) return apellido;
if (nombre) return nombre;
return 'Admin';
}
export function calcularEdad(fechaNacimiento?: string): string | number {
if (!fechaNacimiento) return '-';
const hoy = new Date();
const cumple = new Date(fechaNacimiento);
if (isNaN(cumple.getTime())) return '-';
let edad = hoy.getFullYear() - cumple.getFullYear();
const m = hoy.getMonth() - cumple.getMonth();
if (m < 0 || (m === 0 && hoy.getDate() < cumple.getDate())) {
edad--;
}
return edad;
}
export function isCamaFueraDeGrupo(
cama: { numero: string; grupoId?: string; areaId?: string; sector?: string }
): boolean {
return computeSector(cama?.numero || '') === 'Fuera de Área';
}
export function computeSector(numero: string): "En Área" | "Fuera de Área" {
if (!numero) return 'En Área';
const cleanNumero = String(numero).trim();
// Format is XXX-YY (where XXX is room number, YY is bed number in room)
// Extract room number XXX (first part before hyphen if hyphen exists, or first 3 digits)
const parts = cleanNumero.split('-');
const salaStr = parts[0].trim();
// Fuera de área rule 1: 3XX where XX is an odd number (e.g., 301, 303, 315, 399)
// salaStr length can be 3, starting with '3'
if (salaStr.length === 3 && salaStr.startsWith('3')) {
const xxNum = parseInt(salaStr.slice(1), 10);
if (!isNaN(xxNum) && xxNum % 2 !== 0) {
return 'Fuera de Área';
}
}
// Fuera de área rule 2: 4XX (starts with '4', regardless of digits XX)
if (salaStr.startsWith('4')) {
return 'Fuera de Área';
}
return 'En Área';
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+455
View File
@@ -0,0 +1,455 @@
import { useState } from 'react';
import { Activity, Plus, Search, Calendar, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import type { AcidoBase, Paciente } from '@/types';
interface AcidoBaseProps {
acidosBase: AcidoBase[];
pacientes: Paciente[];
onAgregarAcidoBase: (acidoBase: Omit<AcidoBase, 'id'>) => void;
onEliminarAcidoBase: (id: string) => void;
getPacienteById: (id: string) => Paciente | undefined;
}
const REFERENCIAS = {
ph: { min: 7.35, max: 7.45, unidad: '' },
pco2: { min: 35, max: 45, unidad: 'mmHg' },
po2: { min: 80, max: 100, unidad: 'mmHg' },
hco3: { min: 22, max: 26, unidad: 'mEq/L' },
be: { min: -2, max: 2, unidad: 'mEq/L' },
sato2: { min: 95, max: 100, unidad: '%' },
lactato: { min: 0.5, max: 2.2, unidad: 'mmol/L' },
};
export function AcidoBaseSection({
acidosBase,
pacientes,
onAgregarAcidoBase,
onEliminarAcidoBase,
getPacienteById,
}: AcidoBaseProps) {
const [busqueda, setBusqueda] = useState('');
const [dialogoAbierto, setDialogoAbierto] = useState(false);
// Formulario
const [pacienteSeleccionado, setPacienteSeleccionado] = useState('');
const [busquedaPaciente, setBusquedaPaciente] = useState('');
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
const [hora, setHora] = useState(new Date().toTimeString().slice(0, 5));
const [ph, setPh] = useState('');
const [pco2, setPco2] = useState('');
const [po2, setPo2] = useState('');
const [hco3, setHco3] = useState('');
const [be, setBe] = useState('');
const [sato2, setSato2] = useState('');
const [lactato, setLactato] = useState('');
const [interpretacion, setInterpretacion] = useState('');
const resetFormulario = () => {
setPacienteSeleccionado('');
setBusquedaPaciente('');
setFecha(new Date().toISOString().split('T')[0]);
setHora(new Date().toTimeString().slice(0, 5));
setPh('');
setPco2('');
setPo2('');
setHco3('');
setBe('');
setSato2('');
setLactato('');
setInterpretacion('');
};
const getEstadoParametro = (parametro: keyof typeof REFERENCIAS, valor: number) => {
const ref = REFERENCIAS[parametro];
if (valor < ref.min) return { estado: 'Bajo', color: 'text-blue-600' };
if (valor > ref.max) return { estado: 'Alto', color: 'text-red-600' };
return { estado: 'Normal', color: 'text-green-600' };
};
const pacientesFiltrados = pacientes.filter(p => {
if (!busquedaPaciente) return true;
const texto = `${p.apellido} ${p.nombre} ${p.dni}`.toLowerCase();
return texto.includes(busquedaPaciente.toLowerCase());
});
const interpretarGasometria = () => {
const phVal = parseFloat(ph);
const pco2Val = parseFloat(pco2);
const hco3Val = parseFloat(hco3);
const beVal = parseFloat(be);
if (!phVal || !pco2Val || !hco3Val) return '';
let interpretacion = '';
// Determinar acidosis o alcalosis
if (phVal < 7.35) {
interpretacion += 'Acidemia - ';
if (pco2Val > 45) interpretacion += 'Acidosis Respiratoria';
else if (hco3Val < 22) interpretacion += 'Acidosis Metabólica';
else interpretacion += 'Acidosis Mixta';
} else if (phVal > 7.45) {
interpretacion += 'Alcalemia - ';
if (pco2Val < 35) interpretacion += 'Alcalosis Respiratoria';
else if (hco3Val > 26) interpretacion += 'Alcalosis Metabólica';
else interpretacion += 'Alcalosis Mixta';
} else {
interpretacion += 'pH Normal - ';
if (pco2Val > 45 || hco3Val < 22) interpretacion += 'Compensación en curso';
else interpretacion += 'Equilibrio Ácido-Base';
}
// Agregar información sobre compensación
if (beVal && Math.abs(beVal) > 2) {
interpretacion += beVal > 0 ? ' (Exceso de bases elevado)' : ' (Déficit de bases)';
}
return interpretacion;
};
const handleGuardar = () => {
if (!pacienteSeleccionado || !ph || !pco2 || !po2 || !hco3 || !be || !sato2) return;
const interpretacionAuto = interpretarGasometria();
onAgregarAcidoBase({
pacienteId: pacienteSeleccionado,
fecha,
hora,
ph: parseFloat(ph),
pco2: parseFloat(pco2),
po2: parseFloat(po2),
hco3: parseFloat(hco3),
be: parseFloat(be),
sato2: parseFloat(sato2),
lactato: lactato ? parseFloat(lactato) : undefined,
interpretacion: interpretacion || interpretacionAuto,
});
resetFormulario();
setDialogoAbierto(false);
};
const acidosBaseFiltrados = acidosBase
.filter(ab => {
const paciente = getPacienteById(ab.pacienteId);
if (!paciente) return false;
return !busqueda ||
paciente.nombre.toLowerCase().includes(busqueda.toLowerCase()) ||
paciente.apellido.toLowerCase().includes(busqueda.toLowerCase()) ||
paciente.dni.includes(busqueda);
})
.sort((a, b) => new Date(b.fecha + 'T' + b.hora).getTime() - new Date(a.fecha + 'T' + a.hora).getTime());
const getColorPh = (ph: number) => {
if (ph < 7.2 || ph > 7.6) return 'bg-red-100 text-red-800';
if (ph < 7.35 || ph > 7.45) return 'bg-amber-100 text-amber-800';
return 'bg-green-100 text-green-800';
};
return (
<div className="space-y-6 dark:text-white">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Estados Ácido-Base</h1>
<p className="text-gray-500 dark:text-gray-400">Gasometrías y análisis de equilibrio ácido-base</p>
</div>
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
<DialogTrigger asChild>
<Button onClick={resetFormulario}>
<Plus className="h-4 w-4 mr-2" />
Nueva Gasometría
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Nueva Gasometría Arterial</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-3">
{!pacienteSeleccionado ? (
<div>
<Label>Buscar Paciente *</Label>
<Input
value={busquedaPaciente}
onChange={(e) => setBusquedaPaciente(e.target.value)}
placeholder="Ingrese apellido, nombre o DNI..."
autoFocus
/>
{busquedaPaciente && (
<div className="mt-2 border rounded-md max-h-48 overflow-y-auto">
{pacientesFiltrados.length === 0 ? (
<p className="p-3 text-sm text-gray-500">No se encontraron pacientes</p>
) : (
pacientesFiltrados.map(p => (
<button
key={p.id}
type="button"
onClick={() => setPacienteSeleccionado(p.id)}
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-b-0"
>
{p.apellido}, {p.nombre} - DNI: {p.dni}
</button>
))
)}
</div>
)}
</div>
) : (
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-green-100 text-green-800 px-3 py-1">
{(() => {
const p = pacientes.find(x => x.id === pacienteSeleccionado);
return p ? `${p.apellido}, ${p.nombre} - DNI: ${p.dni}` : '';
})()}
</Badge>
<Button size="sm" variant="ghost" onClick={() => setPacienteSeleccionado('')}>Cambiar</Button>
</div>
)}
</div>
<div>
<Label>Fecha *</Label>
<Input type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
</div>
<div>
<Label>Hora *</Label>
<Input type="time" value={hora} onChange={(e) => setHora(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-7 gap-2">
<div>
<Label>pH *</Label>
<Input
type="number"
step="0.01"
value={ph}
onChange={(e) => setPh(e.target.value)}
placeholder="7.40"
/>
</div>
<div>
<Label>pCO2 *</Label>
<Input
type="number"
value={pco2}
onChange={(e) => setPco2(e.target.value)}
placeholder="40"
/>
</div>
<div>
<Label>pO2 *</Label>
<Input
type="number"
value={po2}
onChange={(e) => setPo2(e.target.value)}
placeholder="85"
/>
</div>
<div>
<Label>HCO3- *</Label>
<Input
type="number"
step="0.1"
value={hco3}
onChange={(e) => setHco3(e.target.value)}
placeholder="24"
/>
</div>
<div>
<Label>BE *</Label>
<Input
type="number"
step="0.1"
value={be}
onChange={(e) => setBe(e.target.value)}
placeholder="0"
/>
</div>
<div>
<Label>SatO2 *</Label>
<Input
type="number"
value={sato2}
onChange={(e) => setSato2(e.target.value)}
placeholder="97"
/>
</div>
<div>
<Label>Lactato</Label>
<Input
type="number"
step="0.1"
value={lactato}
onChange={(e) => setLactato(e.target.value)}
placeholder="1.0"
/>
</div>
</div>
{ph && pco2 && hco3 && (
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<p className="text-sm font-medium text-blue-800 flex items-center gap-2">
<Activity className="h-4 w-4" />
Interpretación automática: {interpretarGasometria()}
</p>
</div>
)}
<div>
<Label>Interpretación / Comentarios</Label>
<textarea
className="w-full p-2 border rounded-md text-sm min-h-[80px]"
value={interpretacion}
onChange={(e) => setInterpretacion(e.target.value)}
placeholder="Interpretación clínica..."
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={() => {
resetFormulario();
setDialogoAbierto(false);
}}>
Cancelar
</Button>
<Button
onClick={handleGuardar}
disabled={!pacienteSeleccionado || !ph || !pco2 || !po2 || !hco3 || !be || !sato2}
>
<Plus className="h-4 w-4 mr-2" />
Guardar Gasometría
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Búsqueda */}
<Card>
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10"
placeholder="Buscar por paciente..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Lista de Gasometrías */}
<div className="space-y-4">
{acidosBaseFiltrados.map((ab) => {
const paciente = getPacienteById(ab.pacienteId);
const phEstado = getEstadoParametro('ph', ab.ph);
return (
<Card key={ab.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-teal-100 rounded-full flex items-center justify-center">
<Activity className="h-5 w-5 text-teal-600" />
</div>
<div>
<h3 className="font-bold">
{paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente no encontrado'}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{ab.fecha}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{ab.hora}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={getColorPh(ab.ph)}>
pH: {ab.ph}
</Badge>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => onEliminarAcidoBase(ab.id)}
>
Eliminar
</Button>
</div>
</div>
<div className="grid grid-cols-3 sm:grid-cols-7 gap-2">
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">pH</p>
<p className={`font-bold ${phEstado.color}`}>{ab.ph}</p>
</div>
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">pCO2</p>
<p className="font-bold">{ab.pco2}</p>
</div>
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">pO2</p>
<p className="font-bold">{ab.po2}</p>
</div>
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">HCO3</p>
<p className="font-bold">{ab.hco3}</p>
</div>
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">BE</p>
<p className="font-bold">{ab.be}</p>
</div>
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">SatO2</p>
<p className="font-bold">{ab.sato2}%</p>
</div>
{ab.lactato && (
<div className="bg-gray-50 p-2 rounded text-center">
<p className="text-xs text-gray-500">Lactato</p>
<p className="font-bold">{ab.lactato}</p>
</div>
)}
</div>
{ab.interpretacion && (
<div className="bg-teal-50 p-3 rounded-lg border border-teal-200">
<p className="text-sm font-medium text-teal-800 flex items-center gap-2">
Interpretación: {ab.interpretacion}
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{acidosBaseFiltrados.length === 0 && (
<div className="text-center py-12 text-gray-400">
<Activity className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">
{busqueda ? 'No se encontraron gasometrías con esa búsqueda' : 'No hay gasometrías registradas'}
</p>
</div>
)}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState } from 'react';
import { Microscope, Search, Calendar, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Cultivo, Paciente, Cama, Internacion } from '@/types';
interface CultivosProps {
cultivos: Cultivo[];
pacientes?: Paciente[];
internaciones?: Internacion[];
camas?: Cama[];
patient?: Paciente;
internacionId?: string;
onAgregarCultivo?: (cultivo: Omit<Cultivo, 'id'>) => void;
onActualizarCultivo?: (id: string, datos: Partial<Cultivo>) => void;
onEliminarCultivo?: (id: string) => void;
getPacienteById?: (id: string) => Paciente | undefined;
getInternacionActivaByPaciente?: (pacienteId: string) => Internacion | undefined;
canEdit?: boolean;
}
export function Cultivos({
cultivos,
pacientes = [],
internaciones = [],
camas = [],
patient,
getPacienteById,
}: CultivosProps) {
const [busqueda, setBusqueda] = useState('');
const [filtroEstado, setFiltroEstado] = useState<'todos' | 'NAF/Pendiente' | 'Parcial' | 'Positivo' | 'Negativo'>('todos');
const findPaciente = (pacienteId: string): Paciente | undefined => {
if (patient && patient.id === pacienteId) return patient;
if (getPacienteById) return getPacienteById(pacienteId);
return pacientes.find(p => p.id === pacienteId);
};
const getCamaNombreForPaciente = (pacienteId: string) => {
const inter = internaciones.find(i => i.pacienteId === pacienteId && i.activa);
if (!inter) return '';
const cama = camas.find(c => c.id === inter.camaId);
return cama ? `Cama ${cama.numero}` : '';
};
const cultivosFiltrados = cultivos.filter(c => {
if (filtroEstado !== 'todos' && c.estado !== filtroEstado) return false;
if (busqueda) {
const term = busqueda.toLowerCase();
const pac = findPaciente(c.pacienteId);
const nombrePac = pac ? `${pac.apellido} ${pac.nombre} ${pac.dni}`.toLowerCase() : '';
return (
c.protocolo?.toLowerCase().includes(term) ||
c.germen?.toLowerCase().includes(term) ||
c.tipoMuestra.toLowerCase().includes(term) ||
nombrePac.includes(term)
);
}
return true;
});
const getEstadoColor = (estado: string) => {
switch (estado) {
case 'NAF/Pendiente': return 'bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300 dark:border dark:border-amber-800';
case 'Parcial': return 'bg-orange-100 text-orange-800 dark:bg-orange-950/80 dark:text-orange-300 dark:border dark:border-orange-800';
case 'Positivo': return 'bg-red-100 text-red-800 dark:bg-red-950/80 dark:text-red-300 dark:border dark:border-red-800';
case 'Negativo': return 'bg-green-100 text-green-800 dark:bg-green-950/80 dark:text-green-300 dark:border dark:border-green-800';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
}
};
const getEstadoLabel = (estado: string) => {
switch (estado) {
case 'NAF/Pendiente': return 'NAF';
case 'Parcial': return 'Parcial';
case 'Positivo': return 'Positivo';
case 'Negativo': return 'Negativo';
default: return estado;
}
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Buscar por protocolo, germen, paciente..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="pl-8"
/>
</div>
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as Cultivo['estado'] | 'todos')}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="NAF/Pendiente">NAF/Pendiente</SelectItem>
<SelectItem value="Parcial">Parcial</SelectItem>
<SelectItem value="Positivo">Positivo</SelectItem>
<SelectItem value="Negativo">Negativo</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
{cultivosFiltrados.map((cultivo) => {
const pac = findPaciente(cultivo.pacienteId);
const camaNombre = getCamaNombreForPaciente(cultivo.pacienteId);
return (
<Card key={cultivo.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div className="flex items-center gap-3">
<div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 ${
cultivo.estado === 'NAF/Pendiente' ? 'bg-amber-100 dark:bg-amber-950/80' :
cultivo.estado === 'Parcial' ? 'bg-orange-100 dark:bg-orange-950/80' :
cultivo.estado === 'Positivo' ? 'bg-red-100 dark:bg-red-950/80' : 'bg-green-100 dark:bg-green-950/80'
}`}>
<Microscope className={`h-5 w-5 ${
cultivo.estado === 'NAF/Pendiente' ? 'text-amber-600 dark:text-amber-400' :
cultivo.estado === 'Parcial' ? 'text-orange-600 dark:text-orange-400' :
cultivo.estado === 'Positivo' ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'
}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{camaNombre && (
<Badge variant="outline" className="text-xs shrink-0 font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800">
{camaNombre}
</Badge>
)}
<h3 className="font-bold text-sm sm:text-base truncate">
{pac ? `${pac.apellido}, ${pac.nombre}` : 'Paciente no encontrado'}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-500 mt-1">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{cultivo.fechaToma}
</span>
<Badge variant="outline" className="text-xs">{cultivo.tipoMuestra}</Badge>
<Badge className={`text-xs ${getEstadoColor(cultivo.estado)}`}>
{getEstadoLabel(cultivo.estado)}
</Badge>
</div>
</div>
</div>
</div>
{cultivo.protocolo && (
<p className="text-xs text-gray-500">
Protocolo: {cultivo.protocolo}
</p>
)}
{cultivo.observaciones && (
<p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2 rounded">
{cultivo.observaciones}
</p>
)}
{cultivo.estado === 'Parcial' && cultivo.germen && (
<div className="bg-orange-50 p-3 rounded-lg border border-orange-200">
<p className="text-sm font-medium text-orange-800 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
PARCIAL - Germen: {cultivo.germen}
</p>
{(cultivo.sensible || cultivo.resistente) && (
<div className="mt-2 space-y-1">
{cultivo.sensible && (
<p className="text-xs text-orange-700">Sensible: {cultivo.sensible}</p>
)}
{cultivo.resistente && (
<p className="text-xs text-orange-700">Resistente: {cultivo.resistente}</p>
)}
</div>
)}
</div>
)}
{cultivo.estado === 'Positivo' && cultivo.germen && (
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<p className="text-sm font-medium text-red-800 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Germen: {cultivo.germen}
</p>
{cultivo.fechaResultado && (
<p className="text-xs text-red-600 mt-1">
Resultado: {cultivo.fechaResultado}
</p>
)}
{(cultivo.sensible || cultivo.resistente) && (
<div className="mt-2 space-y-2">
{cultivo.sensible && (
<div>
<p className="text-xs font-medium text-green-700 mb-1">Sensible a:</p>
<p className="text-sm text-green-800">{cultivo.sensible}</p>
</div>
)}
{cultivo.resistente && (
<div>
<p className="text-xs font-medium text-red-700 mb-1">Resistente a:</p>
<p className="text-sm text-red-800">{cultivo.resistente}</p>
</div>
)}
</div>
)}
</div>
)}
{cultivo.estado === 'Negativo' && (
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
<p className="text-sm font-medium text-green-800 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Cultivo Negativo
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
})}
{cultivosFiltrados.length === 0 && (
<p className="text-center text-gray-500 py-8">
No se encontraron cultivos con los filtros seleccionados
</p>
)}
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
import {
Bed,
Users,
ClipboardList,
FlaskConical,
Activity,
Microscope,
TrendingUp,
AlertCircle,
CheckCircle2,
Clock
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { formatDateDDMMYYYY } from '@/lib/utils';
import type { Vista, Internacion, Paciente, Cultivo } from '@/types';
interface DashboardProps {
estadisticas: {
camasOcupadas: number;
camasDisponibles: number;
camasMantenimiento: number;
totalCamas: number;
totalCamasActivas: number;
camasFueraDeArea: number;
internacionesActivas: number;
totalPacientes: number;
cultivosPendientes: number;
porcentajeOcupacion: number;
};
internaciones: Internacion[];
cultivos: Cultivo[];
onCambiarVista: (vista: Vista) => void;
getPacienteById: (id: string) => Paciente | undefined;
}
export function Dashboard({
estadisticas,
internaciones,
cultivos,
onCambiarVista,
getPacienteById
}: DashboardProps) {
const internacionesActivas = internaciones.filter(i => i.activa).slice(0, 5);
const cultivosRecientes = cultivos
.filter(c => c.estado === 'NAF/Pendiente')
.slice(0, 5);
return (
<div className="space-y-6 dark:text-white">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Resumen del servicio - Clinica Medica</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 dark:bg-green-900 dark:text-green-300 dark:border-green-700">
<CheckCircle2 className="h-3 w-3 mr-1" />
Sistema Operativo
</Badge>
</div>
</div>
{/* Estadísticas Principales */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Bed className="h-4 w-4" />
Ocupación de Camas
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{estadisticas.porcentajeOcupacion}%</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{estadisticas.camasOcupadas}/{estadisticas.totalCamasActivas}</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-400 mt-1">
{estadisticas.camasDisponibles} camas disponibles
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Users className="h-4 w-4" />
Camas fuera de área
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{estadisticas.camasFueraDeArea}</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-400 mt-1">
Total de camas fuera de área
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
<ClipboardList className="h-4 w-4" />
Internaciones Activas
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{estadisticas.internacionesActivas}</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-400 mt-1">
En curso actualmente
</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Microscope className="h-4 w-4" />
Cultivos Pendientes
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{estadisticas.cultivosPendientes}</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-400 mt-1">
Esperando resultados
</p>
</CardContent>
</Card>
</div>
{/* Contenido Principal */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Estado de Camas */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Bed className="h-5 w-5 text-blue-600 dark:text-blue-400" />
Estado de Camas
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => onCambiarVista('camas')}>
Ver todas
</Button>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="bg-green-50 dark:bg-green-950/60 border border-transparent dark:border-green-800/50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-300">Disponibles</span>
</div>
<p className="text-2xl font-bold text-green-700 dark:text-green-200 mt-1">{estadisticas.camasDisponibles}</p>
</div>
<div className="bg-red-50 dark:bg-red-950/60 border border-transparent dark:border-red-800/50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-800 dark:text-red-300">Ocupadas</span>
</div>
<p className="text-2xl font-bold text-red-700 dark:text-red-200 mt-1">{estadisticas.camasOcupadas}</p>
</div>
<div className="bg-amber-50 dark:bg-amber-950/60 border border-transparent dark:border-amber-800/50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-300">Mantenimiento</span>
</div>
<p className="text-2xl font-bold text-amber-700 dark:text-amber-200 mt-1">{estadisticas.camasMantenimiento}</p>
</div>
<div className="bg-blue-50 dark:bg-blue-950/60 border border-transparent dark:border-blue-800/50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">Ocupación</span>
</div>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-200 mt-1">{estadisticas.porcentajeOcupacion}%</p>
</div>
</div>
</CardContent>
</Card>
{/* Internaciones Activas */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-purple-600" />
Internaciones Activas
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => onCambiarVista('internaciones')}>
Ver todas
</Button>
</CardHeader>
<CardContent>
{internacionesActivas.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<ClipboardList className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No hay internaciones activas</p>
</div>
) : (
<div className="space-y-3">
{internacionesActivas.map((internacion) => {
const paciente = getPacienteById(internacion.pacienteId);
return (
<div key={internacion.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/80 border border-transparent dark:border-gray-700/50 rounded-lg">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
{paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente no encontrado'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Ingreso: {internacion.fechaIngresoClinica ? formatDateDDMMYYYY(internacion.fechaIngresoClinica) : 'N/A'}
</p>
</div>
<Badge variant="outline" className="bg-purple-50 text-purple-700 dark:bg-purple-950/60 dark:text-purple-300 dark:border-purple-800">
Activa
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Accesos Rápidos */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Activity className="h-5 w-5 text-green-600" />
Accesos Rápidos
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
onClick={() => onCambiarVista('pacientes')}
>
<Users className="h-6 w-6 text-blue-600" />
<span className="text-sm">Nuevo Paciente</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
onClick={() => onCambiarVista('internaciones')}
>
<ClipboardList className="h-6 w-6 text-purple-600" />
<span className="text-sm">Nueva Internación</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
onClick={() => onCambiarVista('laboratorios')}
>
<FlaskConical className="h-6 w-6 text-amber-600" />
<span className="text-sm">Laboratorio</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
onClick={() => onCambiarVista('cultivos')}
>
<Microscope className="h-6 w-6 text-teal-600" />
<span className="text-sm">Cultivo</span>
</Button>
</div>
</CardContent>
</Card>
{/* Cultivos Pendientes */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Microscope className="h-5 w-5 text-teal-600" />
Cultivos Pendientes
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => onCambiarVista('cultivos')}>
Ver todos
</Button>
</CardHeader>
<CardContent>
{cultivosRecientes.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<Microscope className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No hay cultivos pendientes</p>
</div>
) : (
<div className="space-y-3">
{cultivosRecientes.map((cultivo) => {
const paciente = getPacienteById(cultivo.pacienteId);
return (
<div key={cultivo.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/80 border border-transparent dark:border-gray-700/50 rounded-lg">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
{paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente no encontrado'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{cultivo.tipoMuestra} - {cultivo.fechaToma}
</p>
</div>
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 dark:border dark:border-amber-800">
<Clock className="h-3 w-3 mr-1" />
Pendiente
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More