[200~refact: reestructuración completa a shadcn/ui, nuevo tema tweakcn e interactividad avanzada
This commit is contained in:
parent
277dce6513
commit
63dc9163a5
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -1,15 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RepairIT</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4081
frontend/package-lock.json
generated
4081
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -10,41 +10,31 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"next": "^16.2.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"shadcn": "^4.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"vite": "^8.0.10"
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
frontend/public/capitulo1.md
Normal file
141
frontend/public/capitulo1.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# PROPUESTA DE PROYECTO FINAL - TALLER III
|
||||
## Sistema de Gestión y Trazabilidad para Servicios Técnicos: "RepairIT"
|
||||
|
||||
**Alumno:** Edgar Javier Ortiz
|
||||
**Carrera:** Tecnicatura Superior en Desarrollo de Software
|
||||
**Institución:** IES La Cocha
|
||||
**Año:** 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. ESPECIFICACIONES DEL SISTEMA (ALCANCE)
|
||||
|
||||
### 1.1. Descripción General
|
||||
**RepairIT** es una plataforma web SaaS desacoplada, diseñada específicamente para optimizar la gestión operativa, el control de inventario y la comunicación con el cliente dentro de talleres de reparación tecnológica. El sistema automatiza el flujo de trabajo desde el ingreso del dispositivo hasta su entrega final, ofreciendo un canal público de consulta en tiempo real para los usuarios del servicio.
|
||||
|
||||
### 1.2. Objetivos del Proyecto
|
||||
* **Objetivo General:** Desarrollar e implementar una aplicación web reactiva y escalable que centralice la administración de órdenes de reparación y audite el stock de insumos técnicos, mejorando la transparencia hacia el cliente final mediante identificadores únicos de seguimiento.
|
||||
* **Objetivos Específicos:**
|
||||
* Segmentar la plataforma mediante subdominios funcionales independientes para aislar el panel administrativo de la consulta pública.
|
||||
* Implementar un algoritmo de generación de identificadores únicos no secuenciales (NanoID) para prescindir del uso de contraseñas por parte del cliente.
|
||||
* Automatizar el descuento dinámico de stock de componentes asociados a cada reparación realizada.
|
||||
* Proveer módulos de exportación de datos en formatos PDF y Excel del lado del cliente.
|
||||
|
||||
### 1.3. Alcance del Proyecto (MVP)
|
||||
* **Dentro del Alcance:**
|
||||
* Panel de autenticación (Login) para el personal técnico y administradores mediante tokens JWT.
|
||||
* Módulo de gestión de órdenes de servicio (Creación, asignación de técnico, diagnóstico, presupuesto, estados y cierre).
|
||||
* Módulo de inventario y control de stock de repuestos con alertas de bajo suministro.
|
||||
* Interfaz de consulta pública para clientes basada en parámetros de URL dinámicos.
|
||||
* Motor de exportación de presupuestos a PDF y auditorías de stock a planillas Excel.
|
||||
* **Fuera del Alcance:**
|
||||
* Pasarela de pagos online dentro de la plataforma (las transacciones monetarias se gestionan de forma física o externa al sistema).
|
||||
* Aplicación móvil nativa en esta primera fase del ciclo de vida del software.
|
||||
|
||||
### 1.4. Requisitos Funcionales (RF)
|
||||
* **RF01 - Autenticación de Usuarios Internos:** El sistema debe permitir el inicio de sesión exclusivo a técnicos y administradores registrados mediante credenciales encriptadas.
|
||||
* **RF02 - Registro de Órdenes de Servicio:** El sistema debe permitir la carga de un nuevo equipo ingresado, asociando los datos del cliente, marca, modelo, fallas reportadas y un técnico responsable.
|
||||
* **RF03 - Generación de Enlace Único:** Al registrar un equipo, el sistema debe generar automáticamente un código NanoID seguro y asociarlo a una URL de consulta pública.
|
||||
* **RF04 - Gestión de Estados de Reparación:** El técnico debe poder actualizar el estado del equipo siguiendo un flujo lógico (*Ingresado*, *En Diagnóstico*, *Presupuestado*, *En Reparación*, *Listo para Entregar*, *Entregado*).
|
||||
* **RF05 - Carga de Presupuestos:** El sistema debe permitir asignar un costo de mano de obra y desglosar los componentes utilizados en la reparación para conformar el presupuesto total.
|
||||
* **RF06 - Control de Inventario Automatizado:** Al finalizar una orden de servicio o confirmar el uso de un repuesto, el sistema debe descontar automáticamente las unidades correspondientes de la colección de stock en la base de datos.
|
||||
* **RF07 - Consulta de Estado Pública:** El cliente debe poder visualizar la barra de progreso de su equipo ingresando directamente al enlace único generado, sin requerir la creación de una cuenta o inicio de sesión tradicional.
|
||||
* **RF08 - Exportación Documental:** El administrador debe poder descargar presupuestos estructurados en formato PDF y reportes analíticos de stock de insumos en archivos Excel (.xlsx).
|
||||
|
||||
### 1.5. Requisitos No Funcionales (RNF)
|
||||
* **RNF01 - Rendimiento:** El tiempo de respuesta de las consultas de estado en la interfaz pública no debe superar los 200 milisegundos, optimizado mediante ejecución perimetral.
|
||||
* **RNF02 - Seguridad (Persistencia):** Las contraseñas de los usuarios internos deben almacenarse utilizando algoritmos de hash seguros (bcrypt) en el servidor.
|
||||
* **RNF03 - Seguridad (API):** Todos los endpoints privados de la API RESTful deben estar protegidos mediante middlewares que validen la vigencia y firma de tokens JWT.
|
||||
* **RNF04 - Disponibilidad:** La infraestructura de alojamiento debe garantizar una disponibilidad del servicio del 99.9% utilizando redes de distribución global.
|
||||
* **RNF05 - Integridad de Datos:** La base de datos debe validar que el stock de insumos técnicos nunca adopte valores cuantitativos negativos.
|
||||
* **RNF06 - Interfaz Adaptativa (Responsive):** La interfaz visual debe ser 100% responsive, adaptándose de manera fluida a dispositivos móviles, tablets y computadoras de escritorio.
|
||||
* **RNF07 - Arquitectura Desacoplada (Subdominios):** El sistema debe segmentar sus entornos de producción mediante subdominios independientes: `dashboard.repairit.cloud` para la administración del taller y `seguimiento.repairit.cloud` para el uso exclusivo del cliente.
|
||||
|
||||
---
|
||||
|
||||
## 2. ARQUITECTURA Y TECNOLOGÍAS A UTILIZAR
|
||||
|
||||
Para el desarrollo del ecosistema web de **RepairIT**, se ha seleccionado un stack tecnológico moderno, robusto y completamente desacoplado (basado en el patrón Cliente-Servidor), optimizando tanto el rendimiento en producción como la agilidad en solitario del desarrollador:
|
||||
|
||||
### 2.1. Capa de Presentación (Frontend)
|
||||
* **JavaScript / React.js (Vite):** Biblioteca orientada a componentes para el desarrollo de la interfaz de usuario como una *Single Page Application* (SPA). Facilita el renderizado dinámico, la reutilización de código y evita la recarga completa del navegador, brindando una experiencia de usuario fluida e instantánea.
|
||||
* **Tailwind CSS (v4):** Framework de diseño basado en clases utilitarias de última generación. Permite construir una interfaz adaptativa, limpia y minimalista de forma ágil, reduciendo drásticamente el peso de las hojas de estilo CSS globales.
|
||||
* **shadcn/ui:** Colección de componentes de interfaz reutilizables, accesibles y altamente estéticos que se copian de manera nativa en el proyecto. Utilizado para maquetar modales de confirmación avanzadas, tablas de datos y layouts del Dashboard operativo.
|
||||
* **React Router:** Motor de enrutamiento dinámico encargado de gestionar la navegación interna del lado del cliente. Permite capturar los parámetros variables de las URLs (como el código de seguimiento) y procesar las vistas específicas sin interferencia del servidor.
|
||||
|
||||
### 2.2. Capa de Lógica de Negocio y Servidor (Backend)
|
||||
* **Node.js y Express.js:** Entorno de ejecución en tiempo de reloj de JavaScript y framework minimalista para la construcción de la API RESTful. Express se encarga de la exposición de endpoints lógicos, el control de rutas del servidor, la gestión de middlewares de seguridad (validación de sesiones JWT) y el procesamiento de la lógica de negocio.
|
||||
* **NanoID:** Librería de generación de identificadores únicos por software. Desarrolla strings alfanuméricos compactos, seguros y no secuenciales, ideales para generar los tokens de consulta pública de los clientes sin exponer los IDs internos secuenciales de la base de datos.
|
||||
* **jsPDF y SheetJS:** Módulos especializados para la exportación de información del lado del cliente (*Client-side data exporting*). **jsPDF** procesa presupuestos estructurados y órdenes de servicio en formato PDF cerrado, mientras que **SheetJS** convierte las colecciones de datos del inventario a matrices nativas de Excel (.xlsx), ahorrando recursos de cómputo en el servidor.
|
||||
|
||||
### 2.3. Capa de Persistencia (Base de Datos)
|
||||
* **MongoDB:** Base de datos No Relacional (NoSQL) orientada a documentos JSON/BSON. Su esquema flexible y dinámico permite almacenar el histórico de las reparaciones técnicas con campos variables (debido a las diferencias físicas entre dispositivos) sin la necesidad de ejecutar migraciones complejas de datos.
|
||||
|
||||
### 2.4. Capa de Red e Infraestructura
|
||||
* **Cloudflare:** Plataforma de red global utilizada para la delegación DNS, gestión de certificados SSL automáticos y seguridad perimetral. Permite configurar y aislar las reglas de enrutamiento para los subdominios operativos (`dashboard.` y `seguimiento.`).
|
||||
* **Cloudflare Workers:** Infraestructura *serverless* orientada a la computación en el borde (*Edge Computing*). Hospeda y ejecuta el código del backend en los nodos perimetrales más cercanos a la ubicación geográfica del usuario, disminuyendo drásticamente la latencia de las conexiones HTTP y asegurando costos operativos iniciales nulos ($0 USD).
|
||||
|
||||
---
|
||||
|
||||
## 3. METODOLOGÍA DE DESARROLLO
|
||||
|
||||
Para la gestión y ejecución del proyecto **RepairIT**, se ha seleccionado una **metodología híbrida adaptada para desarrollo individual**. Este enfoque estratégico combina el rigor predictivo de los modelos tradicionales para la fase de planificación, con la flexibilidad y velocidad de los marcos ágiles para la fase de construcción del software.
|
||||
|
||||
Dado que el proyecto es llevado a cabo por un único desarrollador, esta hibridación optimiza los tiempos de entrega al eliminar la sobrecarga administrativa de las ceremonias grupales (como en Scrum puro), sin perder el control de la calidad ni la trazabilidad del proceso.
|
||||
|
||||
La metodología se estructura en dos componentes operativos fundamentales:
|
||||
|
||||
### 3.1. Planificación Predictiva (Roadmap Estratégico)
|
||||
Se establece un *Roadmap* lineal dividido en seis fases macro secuenciales. Bajo un enfoque *Frontend-First*, se prioriza la construcción de la interfaz visual de forma local, postergando la configuración de la infraestructura de red perimetral para las etapas finales de integración y despliegue:
|
||||
|
||||
* **Fase I: Desarrollo de Interfaces (Frontend-First):** Maquetación y diseño de la aplicación web utilizando React y componentes de shadcn/ui. Se implementa el enrutamiento dinámico para simular la navegación y se usan datos simulados (*mock data*) para validar de forma local las interfaces del Dashboard y el Seguimiento.
|
||||
* **Fase II: Diseño y Modelado de Datos:** Definición de esquemas no relacionales en la base de datos (MongoDB) y estructuración de las colecciones necesarias para soportar la información y los estados que ya fueron planteados en la interfaz visual.
|
||||
* **Fase III: Lógica de Negocio y Servidor (Backend):** Construcción local de la API RESTful bajo Node.js y Express. Desarrollo de los endpoints, controladores de rutas, lógica de autenticación y algoritmos esenciales (como la generación de tokens NanoID).
|
||||
* **Fase IV: Infraestructura y Entorno de Red:** Configuración avanzada de la capa perimetral en la nube (Cloudflare), creación de reglas de enrutamiento para los subdominios (`dashboard.` y `seguimiento.`), asignación de certificados SSL y preparación de los entornos para producción.
|
||||
* **Fase V: Integración y Consumo de la API:** Conexión de ambas capas mediante peticiones asincrónicas HTTP. Se reemplazan los datos simulados de la Fase I por el consumo real de los servicios del backend a través de la infraestructura de red configurada.
|
||||
* **Fase VI: Extensibilidad y Módulos de Reportes:** Implementación de las herramientas de auditoría para el taller, desarrollando los componentes de exportación de archivos dinámicos (documentación en PDF con jsPDF y planillas de stock en Excel con SheetJS).
|
||||
|
||||
### 3.2. Ejecución Ágil (Marco Kanban)
|
||||
Una vez delimitado el Roadmap, la fase de codificación diaria se gestiona bajo un enfoque puramente ágil e iterativo para absorber cambios técnicos de manera fluida:
|
||||
|
||||
* **Gestión Visual del Flujo (Kanban):** Se utiliza un tablero de tareas continuo (subdividido en *Backlog, To-Do, In Progress, Testing y Done*) en el repositorio local para controlar el inventario de funciones. Esto permite concentrar el esfuerzo en un único requerimiento por ciclo, optimizando la tasa de entrega y evitando la dispersión cognitiva.
|
||||
* **Desarrollo Incremental y Refactorización (XP):** Se prioriza la entrega de un Producto Mínimo Viable (MVP) funcional en cada fase. El software se somete a ciclos constantes de refactorización para mantener la cohesión del código, asegurar el desacoplamiento de componentes en React y garantizar un despliegue limpio y escalable en la infraestructura del servidor.
|
||||
|
||||
---
|
||||
|
||||
## 4. VIABILIDAD DEL PROYECTO
|
||||
|
||||
### 4.1. Viabilidad Técnica
|
||||
La factibilidad técnica de **RepairIT** está plenamente respaldada por la madurez, documentación y compatibilidad del ecosistema seleccionado. El uso de React en su versión moderna junto con Express permite un desacoplamiento perfecto entre la interfaz de usuario y la lógica del servidor, facilitando el mantenimiento independiente de cada capa.
|
||||
|
||||
Asimismo, la persistencia en MongoDB ofrece la flexibilidad necesaria para modificar los esquemas de los históricos de reparación sin necesidad de realizar migraciones complejas de bases de datos relacionales. La infraestructura perimetral provista por Cloudflare mitiga los riesgos de caídas del servicio y simplifica la administración de los subdominios funcionales de forma centralizada.
|
||||
|
||||
### 4.2. Viabilidad Económica y Operativa
|
||||
El modelo de costos operativos para la puesta en marcha del proyecto se ha diseñado bajo una premisa de optimización de recursos, reduciendo la inversión inicial a cero:
|
||||
|
||||
* **Capa de Infraestructura y Servidor:** El despliegue inicial de la plataforma se estructurará sobre una arquitectura serverless aprovechando la capa gratuita de Cloudflare Workers. Esto permite anular los costos operativos de hosting durante las fases de desarrollo, pruebas y validación académica, garantizando un presupuesto de $0 USD para la puesta en marcha del MVP. El modelo contempla una escalabilidad elástica: en caso de una adopción comercial masiva que supere las 100.000 peticiones diarias, el sistema migrará al plan premium de Workers ($5 USD/mes), manteniendo la viabilidad económica.
|
||||
* **Persistencia de Datos:** Se utiliza el nivel gratuito de clústeres en la nube (MongoDB Atlas), el cual proporciona hasta 512 MB de almacenamiento. Esta capacidad es suficiente para la persistencia de miles de tickets de servicio técnico activos y registros de inventario sin generar facturación.
|
||||
|
||||
---
|
||||
|
||||
## 5. CRONOGRAMA DE EJECUCIÓN (PLAN DE TRABAJO)
|
||||
|
||||
Para asegurar la entrega oportuna del proyecto final y mantener una tasa de avance constante, las seis fases definidas en el Roadmap estratégico se distribuyen temporalmente en bloques secuenciales a lo largo de un período estimado de 9 semanas:
|
||||
|
||||
* **Semanas 1 - 3 (Fase I - Frontend-First):** Diseño y desarrollo de componentes visuales en React (Dashboard, Login y Seguimiento) utilizando las clases utilitarias de Tailwind CSS y validación de flujos de navegación con datos locales simulados (*mock data*).
|
||||
* **Semana 4 (Fase II - Modelado de Datos):** Configuración del entorno de persistencia en MongoDB, diseño de los esquemas de colecciones y establecimiento de las relaciones lógicas entre clientes, equipos y componentes técnicos.
|
||||
* **Semanas 5 - 6 (Fase III - Lógica de Servidor):** Construcción del servidor local con Express, codificación de controladores, ruteadores de la API RESTful y middlewares de autenticación por tokens JWT.
|
||||
* **Semana 7 (Fase IV - Infraestructura de Red):** Configuración de la capa perimetral en Cloudflare, aprovisionamiento de certificados SSL automatizados y declaración de las reglas de ruteo para el aislamiento de los subdominios público y privado.
|
||||
* **Semana 8 (Fase V - Integración):** Enlace de las capas cliente-servidor mediante peticiones asincrónicas HTTP (`fetch`), depuración de respuestas JSON y validación del comportamiento del sistema de extremo a extremo (*End-to-End*).
|
||||
* **Semana 9 (Fase VI - Extensibilidad):** Programación de los módulos de salida y exportación de archivos dinámicos, generación automatizada de presupuestos impresos con jsPDF y hojas de cálculo de inventario con SheetJS.
|
||||
|
||||
---
|
||||
|
||||
## 6. RESULTADOS ESPERADOS Y CONCLUSIONES
|
||||
|
||||
Con la implementación de **RepairIT**, se proyecta alcanzar un sistema integral de gestión de servicios técnicos que resuelva las problemáticas de comunicación y desorganización administrativa comunes en los talleres de reparación tecnológicos.
|
||||
|
||||
### Indicadores de Éxito del Software:
|
||||
1. **Trazabilidad Absoluta:** Centralización del 100% de los históricos de los equipos ingresados, accesibles mediante un identificador único alfanumérico seguro (NanoID).
|
||||
2. **Optimización del Rendimiento:** Reducción de los tiempos de carga en la consulta de estados de reparación gracias a la ejecución de código en servidores perimetrales de baja latencia (Edge Computing).
|
||||
3. **Reducción de Costos Operativos:** Demostración práctica de la factibilidad de montar una arquitectura de software de nivel empresarial y alta disponibilidad con un costo de mantenimiento mensual nulo ($0 USD) en su etapa de lanzamiento.
|
||||
184
frontend/src/App.css
Normal file
184
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,47 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { LandingPage } from './components/landing-page';
|
||||
import TermsPage from './components/terms-page';
|
||||
import PrivacyPage from './components/privacy-page';
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
function App() {
|
||||
// Layouts
|
||||
import PublicLayout from "@/components/layout/PublicLayout";
|
||||
import DashboardLayout from "@/components/layout/DashboardLayout";
|
||||
|
||||
// Vistas Públicas
|
||||
import LandingPage from "@/views/public/LandingPage";
|
||||
import TrackingPage from "@/views/public/TrackingPage";
|
||||
import LoginPage from "@/views/admin/LoginPage";
|
||||
import PrivacyPage from "@/views/public/PrivacyPage";
|
||||
import TermsPage from "@/views/public/TermsPage";
|
||||
|
||||
// Vistas Privadas (Dashboard)
|
||||
import DashboardPage from "@/views/admin/DashboardPage";
|
||||
import OrdersPage from "@/views/admin/OrdersPage";
|
||||
import InventoryPage from "@/views/admin/InventoryPage";
|
||||
import ProfilePage from "@/views/admin/ProfilePage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
|
||||
{/* Entorno Público (seguimiento.repairit.cloud) */}
|
||||
<Route path="/" element={<PublicLayout />}>
|
||||
<Route index element={<LandingPage />} />
|
||||
<Route path="seguimiento/:id" element={<TrackingPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="privacidad" element={<PrivacyPage />} />
|
||||
<Route path="terminos" element={<TermsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Entorno Administrativo (dashboard.repairit.cloud) */}
|
||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="ordenes" element={<OrdersPage />} />
|
||||
<Route path="inventario" element={<InventoryPage />} />
|
||||
<Route path="perfil" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
</Routes>
|
||||
</Router>
|
||||
<Toaster position="top-center" richColors />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
|
||||
export default function ContactForm() {
|
||||
return (
|
||||
<div className="w-full px-6 py-8 sm:px-10">
|
||||
<form className="w-full text-left" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-2xl font-bold text-foreground">
|
||||
Contactar a Ventas
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Dejanos tus datos y nos pondremos en contacto para ofrecerte el plan ideal para tu taller de reparación.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border/50 mb-6" />
|
||||
|
||||
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactName">Nombre Completo</Label>
|
||||
<Input id="contactName" placeholder="Ej. Juan Pérez" className="bg-background" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workshopName">Nombre del Taller</Label>
|
||||
<Input id="workshopName" placeholder="Ej. PC Fixers" className="bg-background" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactEmail">Correo Electrónico</Label>
|
||||
<Input id="contactEmail" type="email" placeholder="taller@ejemplo.com" className="bg-background" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">Teléfono / WhatsApp</Label>
|
||||
<Input id="contactPhone" type="tel" placeholder="+54 9 11 1234-5678" className="bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactMessage">¿En qué podemos ayudarte?</Label>
|
||||
<Textarea
|
||||
id="contactMessage"
|
||||
placeholder="Contanos sobre tu taller, volumen de reparaciones o consultas sobre nuestros planes..."
|
||||
className="bg-background min-h-[100px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border/50 my-6" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-end gap-3 pb-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto">
|
||||
Cancelar
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" className="w-full sm:w-auto bg-brand hover:bg-brand-light text-white font-medium px-8">
|
||||
Enviar Mensaje
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import ContactForm from './ContactForm';
|
||||
|
||||
export function ContactSection() {
|
||||
return (
|
||||
<section id="contacto" className="bg-background min-h-[60vh] flex flex-col justify-center py-16 relative z-10 border-t border-border/40 w-full">
|
||||
<div className="w-full mx-auto max-w-4xl px-6 text-center">
|
||||
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl text-foreground mb-6">
|
||||
Impulsá tu taller hoy
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground mb-10 max-w-2xl mx-auto">
|
||||
Ponete en contacto con nosotros para conocer más sobre nuestros planes. Descubrí cómo RepairIT puede ayudarte a escalar tu taller.
|
||||
</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="lg" className="px-12 py-6 text-lg bg-brand hover:bg-brand-light text-white font-medium shadow-[0_0_20px_rgba(59,130,246,0.1)]">
|
||||
Contactar a Ventas
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[95vw] sm:max-w-2xl bg-background border-border p-0 overflow-hidden rounded-xl">
|
||||
<ContactForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react';
|
||||
import { BGPattern } from './ui/bg-pattern';
|
||||
import RepairForm from './form-layout';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const Hero = () => {
|
||||
return (
|
||||
<section id="inicio" className="relative overflow-hidden min-h-screen flex flex-col justify-center pt-16">
|
||||
{/* Patron de fondo del hero */}
|
||||
<BGPattern variant="grid" mask="fade-edges" fill="rgba(255, 255, 255, 0.05)" />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tight mb-8">
|
||||
Gestiona tu taller con <br className="hidden md:block" />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-light via-brand to-brand-dark">
|
||||
precisión absoluta
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="max-w-2xl mx-auto text-lg md:text-xl text-text-muted mb-10">
|
||||
Trazabilidad completa de reparaciones, control de inventario y comunicación profesional con tus clientes, todo en una sola plataforma.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="lg" className="w-full sm:w-auto px-8 bg-brand hover:bg-brand-light text-white font-medium">
|
||||
Comenzar Ahora
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[95vw] sm:max-w-4xl lg:max-w-6xl bg-background border-border p-0 overflow-hidden rounded-xl">
|
||||
<RepairForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { ClipboardCheck, Package, MessageSquare } from 'lucide-react'
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="caracteristicas" className="bg-zinc-50 min-h-screen flex flex-col justify-center py-16 dark:bg-transparent relative z-10 w-full">
|
||||
<div className="w-full mx-auto max-w-5xl px-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-balance text-4xl font-bold tracking-tight lg:text-5xl text-foreground">
|
||||
Todo lo que tu taller necesita
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground max-w-2xl mx-auto">
|
||||
Herramientas diseñadas específicamente para agilizar la reparación de hardware y mejorar la experiencia de tus clientes.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mt-8 grid w-full max-w-sm grid-cols-1 gap-6 *:text-center md:max-w-full md:grid-cols-3 md:mt-16">
|
||||
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<ClipboardCheck className="size-6 text-brand" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-semibold text-lg text-foreground">Trazabilidad Total</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">Hacé un seguimiento detallado del estado de cada equipo, desde su ingreso hasta la entrega final al cliente.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Package className="size-6 text-brand" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-semibold text-lg text-foreground">Control de Inventario</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">Gestiona tus repuestos y componentes de forma eficiente para que nunca te falte lo esencial en una reparación.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<MessageSquare className="size-6 text-brand" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-semibold text-lg text-foreground">Comunicación Profesional</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">Mantené a tus clientes informados con reportes claros y presupuestos detallados directamente desde la plataforma.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CardDecorator = ({
|
||||
children
|
||||
}) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]">
|
||||
<div
|
||||
className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
|
||||
<div
|
||||
className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const links = [
|
||||
{ title: 'Inicio', href: '#inicio' },
|
||||
{ title: 'Características', href: '#caracteristicas' },
|
||||
{ title: 'Contacto', href: '#contacto' },
|
||||
];
|
||||
|
||||
export function FooterSection() {
|
||||
return (
|
||||
<footer className="py-12 border-t border-border/40 bg-background z-10 relative">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<span className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Repair<span className="text-brand">IT</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 mb-4 flex flex-wrap justify-center gap-6 text-sm">
|
||||
{links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-150">
|
||||
{link.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center gap-4">
|
||||
<span className="text-muted-foreground block text-center text-sm">
|
||||
© {new Date().getFullYear()} RepairIT. Todos los derechos reservados.
|
||||
</span>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-xs text-text-muted/60">
|
||||
<Link to="/terms" className="hover:text-foreground transition-colors">
|
||||
Términos y Condiciones
|
||||
</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground transition-colors">
|
||||
Privacidad
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
|
||||
export default function RepairForm() {
|
||||
const [deviceType, setDeviceType] = useState("laptop");
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-[90vh] overflow-y-auto px-6 py-8 sm:px-10">
|
||||
<form className="w-full text-left" onSubmit={(e) => e.preventDefault()}>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-2xl font-bold text-foreground">
|
||||
Registrar Nueva Reparación
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Completa los datos del cliente y los detalles técnicos del equipo para generar la orden de servicio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border/50 mb-6" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
|
||||
{/* Columna Izquierda: Información Personal */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground">Información Personal</h4>
|
||||
<p className="text-sm text-muted-foreground">Datos de contacto del cliente.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Nombre</Label>
|
||||
<Input id="firstName" placeholder="Ej. Juan" className="bg-background" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Apellido</Label>
|
||||
<Input id="lastName" placeholder="Ej. Pérez" className="bg-background" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Correo Electrónico</Label>
|
||||
<Input id="email" type="email" placeholder="juan@ejemplo.com" className="bg-background" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Teléfono / WhatsApp</Label>
|
||||
<Input id="phone" type="tel" placeholder="+54 9 11 1234-5678" className="bg-background" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna Derecha: Detalles del Equipo */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground">Detalles del Equipo</h4>
|
||||
<p className="text-sm text-muted-foreground">Especificaciones y fallo reportado.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deviceType">Tipo de Equipo</Label>
|
||||
<Select value={deviceType} onValueChange={setDeviceType}>
|
||||
<SelectTrigger id="deviceType" className="w-full bg-background">
|
||||
<SelectValue placeholder="Selecciona el tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="laptop">Notebook</SelectItem>
|
||||
<SelectItem value="pc">PC de Escritorio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{deviceType === "laptop" ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand">Marca</Label>
|
||||
<Input id="brand" placeholder="Ej. Lenovo, HP..." className="bg-background" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Modelo</Label>
|
||||
<Input id="model" placeholder="Ej. ThinkPad T490" className="bg-background" required />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="specs">Componentes Principales</Label>
|
||||
<Textarea
|
||||
id="specs"
|
||||
placeholder="Ej. Mother ASUS B450, Ryzen 5, 16GB RAM..."
|
||||
className="bg-background min-h-[60px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issue">Descripción del Fallo</Label>
|
||||
<Textarea
|
||||
id="issue"
|
||||
placeholder="Describinos detalladamente el problema..."
|
||||
className="bg-background min-h-[80px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Contraseña del equipo</Label>
|
||||
<span className="text-xs text-muted-foreground">Opcional</span>
|
||||
</div>
|
||||
<Input id="password" type="text" placeholder="Pin o contraseña" className="bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border/50 my-6" />
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-end gap-3 pb-2">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto">
|
||||
Cancelar
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" className="w-full sm:w-auto bg-brand hover:bg-brand-light text-white font-medium px-8">
|
||||
Registrar Reparación
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import Hero from './Hero';
|
||||
import { NavigationBar } from './navigation-bar';
|
||||
import { Features } from './features';
|
||||
import { ContactSection } from './ContactSection';
|
||||
import { FooterSection } from './footer';
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text-main font-sans selection:bg-brand/30 pt-16">
|
||||
<NavigationBar />
|
||||
<main>
|
||||
<Hero />
|
||||
<Features />
|
||||
<ContactSection />
|
||||
</main>
|
||||
<FooterSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/components/layout/DashboardLayout.jsx
Normal file
221
frontend/src/components/layout/DashboardLayout.jsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
SidebarProvider,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarTrigger,
|
||||
SidebarInset
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ClipboardList,
|
||||
Package,
|
||||
LogOut,
|
||||
Wrench,
|
||||
Store,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = () => {
|
||||
toast.success("Sesión cerrada correctamente.");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
// Determinar los Breadcrumbs basados en la ruta actual
|
||||
const getBreadcrumbs = () => {
|
||||
const path = location.pathname;
|
||||
if (path === "/dashboard/ordenes") {
|
||||
return (
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<NavLink to="/dashboard">Panel</NavLink>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Órdenes de Servicio</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
);
|
||||
}
|
||||
if (path === "/dashboard/inventario") {
|
||||
return (
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<NavLink to="/dashboard">Panel</NavLink>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Inventario de Insumos</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
);
|
||||
}
|
||||
if (path === "/dashboard/perfil") {
|
||||
return (
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<NavLink to="/dashboard">Panel</NavLink>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Perfil de Taller</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Inicio</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex min-h-screen w-full bg-background text-foreground font-sans selection:bg-primary/30 selection:text-primary">
|
||||
|
||||
{/* Sidebar Oficial de shadcn/ui */}
|
||||
<Sidebar className="border-r border-border/80 bg-card">
|
||||
|
||||
<SidebarHeader className="p-6 border-b border-border/30">
|
||||
{/* Logo de la Empresa */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold">
|
||||
<Wrench className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-outfit text-lg font-black tracking-tight text-foreground">
|
||||
Repair<span className="text-primary">IT</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="p-4 pt-6">
|
||||
<SidebarMenu className="space-y-1">
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard"}>
|
||||
<NavLink to="/dashboard" end className="flex items-center gap-3 w-full">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>Inicio</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/ordenes"}>
|
||||
<NavLink to="/dashboard/ordenes" className="flex items-center gap-3 w-full">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span>Órdenes de Servicio</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/inventario"}>
|
||||
<NavLink to="/dashboard/inventario" className="flex items-center gap-3 w-full">
|
||||
<Package className="w-4 h-4" />
|
||||
<span>Inventario de Insumos</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-4 border-t border-border/85 space-y-4 bg-card/65">
|
||||
<div className="flex items-center justify-between px-2 w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center font-bold text-xs text-primary uppercase shrink-0">
|
||||
EO
|
||||
</div>
|
||||
<div className="text-xs truncate min-w-0">
|
||||
<span className="text-foreground font-bold block leading-none truncate">Edgar Ortiz</span>
|
||||
<span className="text-muted-foreground block text-[10px] mt-1 truncate">Administrador</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer shrink-0"
|
||||
title="Configuración de Taller"
|
||||
>
|
||||
<NavLink to="/dashboard/perfil">
|
||||
<Settings className="w-4 h-4" />
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="w-full bg-background hover:bg-red-950/20 text-muted-foreground hover:text-red-400 border-border hover:border-red-950/50 text-xs font-bold py-2 rounded-lg transition-all cursor-pointer flex items-center justify-center gap-2 select-none"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
Cerrar Sesión
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
|
||||
</Sidebar>
|
||||
|
||||
{/* Sidebar Inset para contener el área de trabajo y la barra de navegación superior */}
|
||||
<SidebarInset className="flex-1 flex flex-col min-w-0 bg-background">
|
||||
|
||||
{/* Cabecera Superior con Trigger y Breadcrumbs */}
|
||||
<header className="h-16 border-b border-border/80 flex items-center justify-between px-6 bg-card/20 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarTrigger className="text-muted-foreground hover:text-foreground" />
|
||||
<Separator orientation="vertical" className="h-4 !self-center" />
|
||||
<Breadcrumb className="flex items-center">
|
||||
{getBreadcrumbs()}
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono select-none">
|
||||
Taller Central • Activo
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenido Dinámico de las Páginas del Dashboard */}
|
||||
<main className="flex-grow p-6 md:p-8 overflow-y-auto max-w-7xl w-full mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
</SidebarInset>
|
||||
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/layout/PublicLayout.jsx
Normal file
93
frontend/src/components/layout/PublicLayout.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Link, Outlet } from "react-router-dom";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PublicLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col font-sans selection:bg-primary/30 selection:text-primary relative">
|
||||
|
||||
{/* Navbar con Efecto de Desenfoque y Vidrio (Glassmorphism) */}
|
||||
<header className="sticky top-0 z-50 bg-card/60 backdrop-blur-md border-b border-border/80">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
|
||||
{/* Logo del Proyecto (RepairIT) */}
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="font-outfit text-2xl font-black tracking-tight text-foreground">
|
||||
Repair<span className="text-primary">IT</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Menú de Navegación de la Landing - Optimizado para Móviles */}
|
||||
<nav className="flex items-center gap-4 sm:gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Inicio
|
||||
</Link>
|
||||
<a
|
||||
href="#caracteristicas"
|
||||
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Servicios
|
||||
</a>
|
||||
<a
|
||||
href="#precios"
|
||||
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Precios
|
||||
</a>
|
||||
<a
|
||||
href="#faq"
|
||||
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Preguntas
|
||||
</a>
|
||||
<a
|
||||
href="#contacto"
|
||||
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contacto
|
||||
</a>
|
||||
<Separator orientation="vertical" className="hidden md:block h-4 !self-center" />
|
||||
<Button asChild variant="outline" size="sm" className="text-primary border-primary/20 hover:border-primary/40 bg-primary/10 hover:bg-primary/20 font-bold transition-all duration-200">
|
||||
<Link to="/login">Panel Técnico</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenedor Principal (Hijo) con Efectos de Fondo Encapsulados */}
|
||||
<main className="flex-1 flex flex-col relative z-10 overflow-hidden">
|
||||
{/* Efectos de Fondo Decorativos */}
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] pointer-events-none" />
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer Tecnológico - Optimizado para Móviles */}
|
||||
<footer className="bg-card/40 border-t border-border py-8 relative z-10">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="text-center md:text-left">
|
||||
<span className="font-outfit text-xl font-black tracking-tight text-foreground">
|
||||
Repair<span className="text-primary">IT</span>
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Sistema de Trazabilidad para Talleres de Servicio Técnico.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6 text-xs text-muted-foreground font-medium w-full md:w-auto">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/privacidad" className="hover:text-foreground transition-colors">Privacidad</Link>
|
||||
<Link to="/terminos" className="hover:text-foreground transition-colors">Términos</Link>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="hidden sm:block h-4" />
|
||||
<p className="text-muted-foreground/85 text-center sm:text-left">© 2026 RepairIT. Edgar Javier Ortiz.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function NavigationBar({ isSimple = false }) {
|
||||
return (
|
||||
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className={`container mx-auto h-16 max-w-7xl items-center px-4 sm:px-6 lg:px-8 ${isSimple ? 'flex justify-center' : 'grid grid-cols-3'}`}>
|
||||
|
||||
{/* Logo */}
|
||||
<div className={`flex items-center ${isSimple ? '' : 'justify-self-start'}`}>
|
||||
<Link to="/" className="text-xl font-bold tracking-tight text-foreground">
|
||||
Repair<span className="text-brand">IT</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!isSimple && (
|
||||
<>
|
||||
{/* Enlaces centrales (Escritorio únicamente) */}
|
||||
<div className="hidden md:flex justify-self-center">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<a href="/#inicio" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
|
||||
Inicio
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<a href="/#caracteristicas" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
|
||||
Características
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<a href="/#contacto" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
|
||||
Contacto
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
{/* Botones de acción */}
|
||||
<div className="flex items-center gap-4 justify-self-end">
|
||||
<Button variant="ghost" className="hidden sm:inline-flex text-muted-foreground hover:text-foreground">
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import React from 'react';
|
||||
import { NavigationBar } from './navigation-bar';
|
||||
import { FooterSection } from './footer';
|
||||
|
||||
const PrivacyPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text-main font-sans pt-16">
|
||||
<NavigationBar isSimple={true} />
|
||||
<main className="max-w-4xl mx-auto px-6 py-20">
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl font-bold mb-8 text-foreground">Política de Privacidad</h1>
|
||||
<div className="space-y-6 text-text-muted leading-relaxed">
|
||||
<p>
|
||||
En RepairIT, nos tomamos muy en serio la privacidad de tus datos y los de tus clientes.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<FooterSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPage;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import React from 'react';
|
||||
import { NavigationBar } from './navigation-bar';
|
||||
import { FooterSection } from './footer';
|
||||
|
||||
const TermsPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text-main font-sans pt-16">
|
||||
<NavigationBar isSimple={true} />
|
||||
<main className="max-w-4xl mx-auto px-6 py-20">
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl font-bold mb-8 text-foreground">Términos y Condiciones</h1>
|
||||
<div className="space-y-6 text-text-muted leading-relaxed">
|
||||
<p>
|
||||
Bienvenido a RepairIT. Al utilizar nuestra plataforma, aceptas cumplir con los siguientes términos y condiciones.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<FooterSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsPage;
|
||||
78
frontend/src/components/ui/accordion.jsx
Normal file
78
frontend/src/components/ui/accordion.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
174
frontend/src/components/ui/alert-dialog.jsx
Normal file
174
frontend/src/components/ui/alert-dialog.jsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}) {
|
||||
return (<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}) {
|
||||
return (<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
78
frontend/src/components/ui/alert.jsx
Normal file
78
frontend/src/components/ui/alert.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertAction({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -25,10 +29,19 @@ const badgeVariants = cva(
|
|||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const maskClasses = {
|
||||
'fade-edges': '[mask-image:radial-gradient(ellipse_at_center,var(--color-background),transparent)]',
|
||||
'fade-center': '[mask-image:radial-gradient(ellipse_at_center,transparent,var(--color-background))]',
|
||||
'fade-top': '[mask-image:linear-gradient(to_bottom,transparent,var(--color-background))]',
|
||||
'fade-bottom': '[mask-image:linear-gradient(to_bottom,var(--color-background),transparent)]',
|
||||
'fade-left': '[mask-image:linear-gradient(to_right,transparent,var(--color-background))]',
|
||||
'fade-right': '[mask-image:linear-gradient(to_right,var(--color-background),transparent)]',
|
||||
'fade-x': '[mask-image:linear-gradient(to_right,transparent,var(--color-background),transparent)]',
|
||||
'fade-y': '[mask-image:linear-gradient(to_bottom,transparent,var(--color-background),transparent)]',
|
||||
none: '',
|
||||
};
|
||||
|
||||
function geBgImage(variant, fill, size) {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return `radial-gradient(${fill} 1px, transparent 1px)`;
|
||||
case 'grid':
|
||||
return `linear-gradient(to right, ${fill} 1px, transparent 1px), linear-gradient(to bottom, ${fill} 1px, transparent 1px)`;
|
||||
case 'diagonal-stripes':
|
||||
return `repeating-linear-gradient(45deg, ${fill}, ${fill} 1px, transparent 1px, transparent ${size}px)`;
|
||||
case 'horizontal-lines':
|
||||
return `linear-gradient(to bottom, ${fill} 1px, transparent 1px)`;
|
||||
case 'vertical-lines':
|
||||
return `linear-gradient(to right, ${fill} 1px, transparent 1px)`;
|
||||
case 'checkerboard':
|
||||
return `linear-gradient(45deg, ${fill} 25%, transparent 25%), linear-gradient(-45deg, ${fill} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${fill} 75%), linear-gradient(-45deg, transparent 75%, ${fill} 75%)`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const BGPattern = ({
|
||||
variant = 'grid',
|
||||
mask = 'none',
|
||||
size = 24,
|
||||
fill = '#252525',
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const bgSize = `${size}px ${size}px`;
|
||||
const backgroundImage = geBgImage(variant, fill, size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute inset-0 z-0 size-full', maskClasses[mask], className)}
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: bgSize,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BGPattern.displayName = 'BGPattern';
|
||||
export { BGPattern };
|
||||
121
frontend/src/components/ui/breadcrumb.jsx
Normal file
121
frontend/src/components/ui/breadcrumb.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Breadcrumb({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbList({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}>
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
|
||||
{...props}>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
|
|
@ -1,29 +1,37 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -33,15 +41,23 @@ const buttonVariants = cva(
|
|||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
|
|||
|
|
@ -2,49 +2,113 @@ import * as React from "react"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
function CardAction({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5">
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
const CpuArchitecture = ({
|
||||
className,
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
text = "R-IT",
|
||||
showCpuConnections = true,
|
||||
animateText = true,
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
className={cn("text-muted", className)}
|
||||
width={width}
|
||||
height={height}
|
||||
// viewBox="0 0 200 100" // Original
|
||||
// Chip is at x=85, y=40, width=30, height=20.
|
||||
// Pins stick out about 10 units in each direction.
|
||||
viewBox="70 25 60 50">
|
||||
|
||||
{/* CPU Box */}
|
||||
<g>
|
||||
{/* Cpu connections (Pins) */}
|
||||
{showCpuConnections && (
|
||||
<g fill="url(#cpu-connection-gradient)">
|
||||
{/* Top pins */}
|
||||
<rect x="92" y="37" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="100" y="37" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="108" y="37" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="116" y="37" width="2.5" height="5" rx="0.7" />
|
||||
|
||||
{/* Bottom pins */}
|
||||
<rect x="92" y="58" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="100" y="58" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="108" y="58" width="2.5" height="5" rx="0.7" />
|
||||
<rect x="116" y="58" width="2.5" height="5" rx="0.7" />
|
||||
|
||||
{/* Left pins */}
|
||||
<rect x="82" y="45" width="5" height="2.5" rx="0.7" />
|
||||
<rect x="82" y="52.5" width="5" height="2.5" rx="0.7" />
|
||||
|
||||
{/* Right pins */}
|
||||
<rect x="128" y="45" width="5" height="2.5" rx="0.7" />
|
||||
<rect x="128" y="52.5" width="5" height="2.5" rx="0.7" />
|
||||
</g>
|
||||
)}
|
||||
{/* Main CPU Rectangle */}
|
||||
<rect
|
||||
x="85"
|
||||
y="40"
|
||||
width="45"
|
||||
height="20"
|
||||
rx="2"
|
||||
fill="#181818"
|
||||
filter="url(#cpu-light-shadow)" />
|
||||
{/* CPU Text */}
|
||||
<text
|
||||
x="107.5"
|
||||
y="52.5"
|
||||
fontSize="7.5"
|
||||
textAnchor="middle"
|
||||
fill={animateText ? "url(#cpu-text-gradient)" : "white"}
|
||||
fontWeight="600"
|
||||
letterSpacing="0.05em">
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<filter id="cpu-light-shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="1.5" dy="1.5" stdDeviation="1" floodColor="black" floodOpacity="0.3" />
|
||||
</filter>
|
||||
{/* Cpu connection gradient */}
|
||||
<linearGradient id="cpu-connection-gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#888888" />
|
||||
<stop offset="100%" stopColor="#333333" />
|
||||
</linearGradient>
|
||||
{/* Add CPU Text Gradient */}
|
||||
<linearGradient id="cpu-text-gradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#60a5fa">
|
||||
<animate
|
||||
attributeName="offset"
|
||||
values="-2; -1; 0"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 0.5; 1"
|
||||
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
|
||||
</stop>
|
||||
<stop offset="25%" stopColor="white">
|
||||
<animate
|
||||
attributeName="offset"
|
||||
values="-1; 0; 1"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 0.5; 1"
|
||||
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
|
||||
</stop>
|
||||
<stop offset="50%" stopColor="#60a5fa">
|
||||
<animate
|
||||
attributeName="offset"
|
||||
values="0; 1; 2;"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 0.5; 1"
|
||||
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
|
||||
</stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CpuArchitecture };
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
235
frontend/src/components/ui/dropdown-menu.jsx
Normal file
235
frontend/src/components/ui/dropdown-menu.jsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -2,18 +2,21 @@ import * as React from "react"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
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 transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true" />
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full 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 md:absolute md:w-auto ",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
29
frontend/src/components/ui/progress.jsx
Normal file
29
frontend/src/components/ui/progress.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||
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}
|
||||
{...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)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
|
@ -1,23 +1,25 @@
|
|||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef((
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
|
|
|||
138
frontend/src/components/ui/sheet.jsx
Normal file
138
frontend/src/components/ui/sheet.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({
|
||||
...props
|
||||
}) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-heading text-base font-medium text-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
657
frontend/src/components/ui/sidebar.jsx
Normal file
657
frontend/src/components/ui/sidebar.jsx
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
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,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
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"
|
||||
|
||||
const SidebarContext = React.createContext(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
|
||||
}) {
|
||||
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) => {
|
||||
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) => {
|
||||
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(() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style
|
||||
}
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
|
||||
}
|
||||
}
|
||||
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 hidden text-sidebar-foreground 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"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// 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="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
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(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"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",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background 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
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
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
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
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 group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_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
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "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
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
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
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
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,
|
||||
}
|
||||
15
frontend/src/components/ui/skeleton.jsx
Normal file
15
frontend/src/components/ui/skeleton.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
48
frontend/src/components/ui/sonner.jsx
Normal file
48
frontend/src/components/ui/sonner.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({
|
||||
...props
|
||||
}) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)"
|
||||
}
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
@ -2,17 +2,19 @@ import * as React from "react"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
function Textarea({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
|
|||
51
frontend/src/components/ui/tooltip.jsx
Normal file
51
frontend/src/components/ui/tooltip.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}) {
|
||||
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow
|
||||
className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
19
frontend/src/hooks/use-mobile.js
Normal file
19
frontend/src/hooks/use-mobile.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState(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
|
||||
}
|
||||
|
|
@ -5,140 +5,195 @@
|
|||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-brand-light: #60a5fa; /* Blue 400 */
|
||||
--color-brand: #3b82f6; /* Blue 500 */
|
||||
--color-brand-dark: #2563eb; /* Blue 600 */
|
||||
--color-background: #09090b; /* Zinc 950 (Almost Black) */
|
||||
--color-surface: #18181b; /* Zinc 900 */
|
||||
--color-surface-hover: #27272a;/* Zinc 800 */
|
||||
--color-text-main: #f8fafc; /* Slate 50 */
|
||||
--color-text-muted: #a1a1aa; /* Zinc 400 */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text-main);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: Inter, sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 1.15rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1.0000 0 0);
|
||||
--foreground: oklch(0.3211 0 0);
|
||||
--card: oklch(1.0000 0 0);
|
||||
--card-foreground: oklch(0.3211 0 0);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover-foreground: oklch(0.3211 0 0);
|
||||
--primary: oklch(0.6225 0.2041 259.9027);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9665 0.0045 258.3247);
|
||||
--secondary-foreground: oklch(0.4419 0.0375 257.2811);
|
||||
--muted: oklch(0.9846 0.0017 247.8389);
|
||||
--muted-foreground: oklch(0.5471 0.0321 263.2921);
|
||||
--accent: oklch(0.9510 0.0267 237.5723);
|
||||
--accent-foreground: oklch(0.3742 0.1844 263.9420);
|
||||
--destructive: oklch(0.6496 0.2362 26.9032);
|
||||
--border: oklch(0.9271 0.0075 260.7315);
|
||||
--input: oklch(0.9271 0.0075 260.7315);
|
||||
--ring: oklch(0.6225 0.2041 259.9027);
|
||||
--radius: 1.15rem;
|
||||
--sidebar: oklch(0.9846 0.0017 247.8389);
|
||||
--sidebar-foreground: oklch(0.3211 0 0);
|
||||
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.9510 0.0267 237.5723);
|
||||
--sidebar-accent-foreground: oklch(0.3742 0.1844 263.9420);
|
||||
--sidebar-border: oklch(0.9271 0.0075 260.7315);
|
||||
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--chart-1: oklch(0.6225 0.2041 259.9027);
|
||||
--chart-2: oklch(0.5469 0.2507 262.8085);
|
||||
--chart-3: oklch(0.4902 0.2693 263.7106);
|
||||
--chart-4: oklch(0.4234 0.2370 263.9162);
|
||||
--chart-5: oklch(0.3742 0.1844 263.9420);
|
||||
--font-sans: Inter, sans-serif;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.2046 0 0);
|
||||
--foreground: oklch(0.9219 0 0);
|
||||
--card: oklch(0.2686 0 0);
|
||||
--card-foreground: oklch(0.9219 0 0);
|
||||
--popover: oklch(0.2686 0 0);
|
||||
--popover-foreground: oklch(0.9219 0 0);
|
||||
--primary: oklch(0.6225 0.2041 259.9027);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.2686 0 0);
|
||||
--secondary-foreground: oklch(0.9219 0 0);
|
||||
--muted: oklch(0.2393 0 0);
|
||||
--muted-foreground: oklch(0.7155 0 0);
|
||||
--accent: oklch(0.5802 0.1915 259.7416);
|
||||
--accent-foreground: oklch(0.8820 0.0588 253.9688);
|
||||
--destructive: oklch(0.6496 0.2362 26.9032);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.3715 0 0);
|
||||
--input: oklch(0.3715 0 0);
|
||||
--ring: oklch(0.6225 0.2041 259.9027);
|
||||
--sidebar: oklch(0.2046 0 0);
|
||||
--sidebar-foreground: oklch(0.9219 0 0);
|
||||
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.6225 0.2041 259.9027);
|
||||
--sidebar-accent-foreground: oklch(0.8820 0.0588 253.9688);
|
||||
--sidebar-border: oklch(0.3715 0 0);
|
||||
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
|
||||
--chart-1: oklch(0.7122 0.1526 254.9868);
|
||||
--chart-2: oklch(0.6225 0.2041 259.9027);
|
||||
--chart-3: oklch(0.5739 0.2334 262.7735);
|
||||
--chart-4: oklch(0.6225 0.2041 259.9027);
|
||||
--chart-5: oklch(0.6225 0.2041 259.9027);
|
||||
--radius: 1.15rem;
|
||||
--font-sans: Inter, sans-serif;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0;
|
||||
--shadow-offset-y: 1px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
178
frontend/src/views/admin/DashboardPage.jsx
Normal file
178
frontend/src/views/admin/DashboardPage.jsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, ShieldAlert, PackageCheck, Wrench, ArrowUpRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000); // Retraso de 1 segundo para ver el skeleton de carga
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
|
||||
{/* Grid de Skeletons para tarjetas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<Skeleton className="h-[280px] w-full rounded-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-[280px] w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
|
||||
{/* Cabecera interna */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Panel de Control
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-light">
|
||||
Resumen operativo del taller y control de stock de insumos técnicos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid de Estadísticas Rápidas mediante Cards de shadcn/ui */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* Card 1: Órdenes Activas */}
|
||||
<Card className="bg-card/45 border-border shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Órdenes Activas</span>
|
||||
<Wrench className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-3xl font-outfit font-black text-foreground block">12</span>
|
||||
<span className="text-[10px] text-primary block font-medium mt-1">4 en diagnóstico, 8 en reparación</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Entregados este Mes */}
|
||||
<Card className="bg-card/45 border-border shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Entregas del Mes</span>
|
||||
<PackageCheck className="h-4 w-4 text-emerald-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-3xl font-outfit font-black text-foreground block">45</span>
|
||||
<span className="text-[10px] text-emerald-400 block font-medium mt-1">+15% respecto al mes anterior</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Repuestos en Stock */}
|
||||
<Card className="bg-card/45 border-border shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Items en Inventario</span>
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-3xl font-outfit font-black text-foreground block">84</span>
|
||||
<span className="text-[10px] text-muted-foreground block font-medium mt-1">Distribuidos en 14 categorías</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 4: Alertas de Stock */}
|
||||
<Card className="bg-destructive/5 border-destructive/20 shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<span className="text-xs font-semibold text-destructive/80 uppercase tracking-wider">Alertas de Stock</span>
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<span className="text-3xl font-outfit font-black text-destructive block">2</span>
|
||||
<span className="text-[10px] text-destructive/80 block font-medium mt-1">Requieren compra urgente</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Tabla de Órdenes Recientes mediante Card de shadcn */}
|
||||
<Card className="lg:col-span-2 bg-card/20 border-border shadow-sm">
|
||||
<CardHeader className="border-b border-border/50 py-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
|
||||
Ingresos Recientes
|
||||
</CardTitle>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono">Actualizado hace 5m</span>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground text-xs font-light leading-relaxed max-w-sm">
|
||||
Aquí se desplegará el listado interactivo con filtros rápidos de diagnóstico. Ya podés probar la pestaña de Órdenes para dar de alta dispositivos.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline" className="mt-4 border-border text-xs gap-1 font-bold">
|
||||
<Link to="/dashboard/ordenes">
|
||||
Ir a Registro de Dispositivos <ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alertas de Stock Detalladas mediante Alert de shadcn/ui */}
|
||||
<Card className="bg-card/20 border-border shadow-sm">
|
||||
<CardHeader className="border-b border-border/50 py-4">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
|
||||
Críticos de Inventario
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
|
||||
<AlertCircle className="h-4 w-4 stroke-destructive" />
|
||||
<div className="ml-2">
|
||||
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
|
||||
<span>Pasta Térmica Arctic MX-6</span>
|
||||
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
|
||||
Mínimo requerido: 5 jeringas | Actual en stock: 2 jeringas.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
|
||||
<AlertCircle className="h-4 w-4 stroke-destructive" />
|
||||
<div className="ml-2">
|
||||
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
|
||||
<span>Alcohol Isopropílico 1L</span>
|
||||
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
|
||||
Mínimo requerido: 3 botellas | Actual en stock: 1 botella.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/views/admin/InventoryPage.jsx
Normal file
36
frontend/src/views/admin/InventoryPage.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Package } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function InventoryPage() {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Inventario de Insumos
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-light">
|
||||
Control de stock y alertas de reposición de componentes técnicos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/25 border-border shadow-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
|
||||
<Package className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground/90 font-bold text-sm">Módulo de Inventario en Desarrollo</p>
|
||||
<p className="max-w-md mx-auto text-xs text-muted-foreground leading-relaxed font-light">
|
||||
En la siguiente iteración de la Fase I implementaremos el control cuantitativo de insumos, auditoría automática de repuestos y la simulación de alerta de bajo suministro.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="text-xs font-bold border-border" onClick={() => toast.info("Módulo en desarrollo.")}>
|
||||
Simular Escaneo de Stock
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/views/admin/LoginPage.jsx
Normal file
112
frontend/src/views/admin/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = (e) => {
|
||||
e.preventDefault();
|
||||
// Simular el inicio de sesión y redirigir al Dashboard
|
||||
toast.success("¡Acceso concedido!", {
|
||||
description: `Bienvenido de vuelta, técnico.`
|
||||
});
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-grow flex items-center justify-center bg-background px-6 py-12 relative overflow-hidden">
|
||||
|
||||
{/* Background Glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-primary/5 rounded-full blur-[80px] pointer-events-none" />
|
||||
|
||||
<Card className="max-w-md w-full bg-card/40 border-border p-8 rounded-2xl shadow-2xl backdrop-blur relative z-10 space-y-6">
|
||||
|
||||
{/* Título y descripción */}
|
||||
<div className="text-center space-y-2">
|
||||
<CardTitle className="font-outfit text-2xl font-bold text-foreground tracking-tight border-0 pb-0">
|
||||
Acceso al Panel Técnico
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground font-light">
|
||||
Ingresa tus credenciales autorizadas para gestionar las órdenes y el inventario.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-email" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
|
||||
Correo Electrónico
|
||||
</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tecnico@repairit.cloud"
|
||||
className="w-full bg-background/85 border-border rounded-lg px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-password" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
|
||||
Contraseña
|
||||
</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-background/85 border-border rounded-lg px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recordarme en este dispositivo */}
|
||||
<div className="flex items-center space-x-2 pt-1">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) => setRememberMe(checked)}
|
||||
className="border-border focus-visible:ring-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="remember"
|
||||
className="text-xs text-muted-foreground font-light cursor-pointer select-none"
|
||||
>
|
||||
Recordarme en este dispositivo
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm py-2.5 rounded-lg shadow-lg shadow-primary/15 transition-all mt-3 cursor-pointer select-none"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center pt-2">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => navigate("/")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground font-medium transition-colors cursor-pointer select-none"
|
||||
>
|
||||
← Volver a la consulta pública
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
frontend/src/views/admin/OrdersPage.jsx
Normal file
472
frontend/src/views/admin/OrdersPage.jsx
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import { PlusCircle, ClipboardList, Trash2, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function OrdersPage() {
|
||||
// Estado para la tabla dinámica de órdenes de servicio
|
||||
const [orders, setOrders] = useState([
|
||||
{
|
||||
id: "RT-2026-ASUS",
|
||||
clientName: "Edgar Javier Ortiz",
|
||||
clientDni: "44.375.912",
|
||||
deviceType: "Notebook",
|
||||
deviceModel: "ASUS ROG Strix G15",
|
||||
serial: "SN-98218-ASUS",
|
||||
issue: "Calentamiento excesivo y reinicios aleatorios.",
|
||||
status: "presupuestado",
|
||||
date: "05/06/2026"
|
||||
},
|
||||
{
|
||||
id: "RT-2026-MOCK",
|
||||
clientName: "Cliente Registrado",
|
||||
clientDni: "29.987.654",
|
||||
deviceType: "Celular",
|
||||
deviceModel: "Samsung S23 Ultra",
|
||||
serial: "SN-PENDIENTE",
|
||||
issue: "Pantalla astillada tras caída física y puerto de carga flojo.",
|
||||
status: "diagnostico",
|
||||
date: "08/06/2026"
|
||||
}
|
||||
]);
|
||||
|
||||
// Pestaña activa
|
||||
const [activeTab, setActiveTab] = useState("registro");
|
||||
|
||||
// Estados del formulario
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientDni, setClientDni] = useState("");
|
||||
const [clientPhone, setClientPhone] = useState("");
|
||||
const [clientEmail, setClientEmail] = useState("");
|
||||
const [deviceType, setDeviceType] = useState("Notebook");
|
||||
const [deviceModel, setDeviceModel] = useState("");
|
||||
const [serial, setSerial] = useState("");
|
||||
const [issue, setIssue] = useState("");
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
|
||||
// Registrar orden
|
||||
const handleRegisterOrder = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!acceptTerms) {
|
||||
toast.error("Debe aceptar los términos de recepción técnica.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trackingCode = `RT-2026-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
|
||||
const newOrder = {
|
||||
id: trackingCode,
|
||||
clientName,
|
||||
clientDni,
|
||||
deviceType,
|
||||
deviceModel,
|
||||
serial: serial || "SN-PENDIENTE",
|
||||
issue,
|
||||
status: "ingresado",
|
||||
date: new Date().toLocaleDateString("es-AR")
|
||||
};
|
||||
|
||||
setOrders([newOrder, ...orders]);
|
||||
|
||||
toast.success("¡Dispositivo Registrado!", {
|
||||
description: `Código de seguimiento: ${trackingCode}.`,
|
||||
});
|
||||
|
||||
// Resetear formulario y mover a la pestaña de listado
|
||||
setClientName("");
|
||||
setClientDni("");
|
||||
setClientPhone("");
|
||||
setClientEmail("");
|
||||
setDeviceType("Notebook");
|
||||
setDeviceModel("");
|
||||
setSerial("");
|
||||
setIssue("");
|
||||
setAcceptTerms(false);
|
||||
setActiveTab("control");
|
||||
};
|
||||
|
||||
// Eliminar orden localmente (simulado)
|
||||
const handleDeleteOrder = (id) => {
|
||||
setOrders(orders.filter(o => o.id !== id));
|
||||
toast.error(`Orden ${id} eliminada del listado.`);
|
||||
};
|
||||
|
||||
// Manejar cambio de estado en la tabla
|
||||
const handleStatusChange = (id, newStatus) => {
|
||||
setOrders(prev => prev.map(o => o.id === id ? { ...o, status: newStatus } : o));
|
||||
toast.success(`Estado de orden ${id} actualizado`, {
|
||||
description: `Nuevo estado: ${newStatus.toUpperCase()}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Selector interactivo de estado de tipo Dropdown Menu de shadcn
|
||||
const getStatusDropdown = (orderId, currentStatus) => {
|
||||
let colorClass = "";
|
||||
let statusLabel = "";
|
||||
switch (currentStatus) {
|
||||
case "ingresado":
|
||||
colorClass = "bg-sky-500/10 text-sky-400 border-sky-500/25 hover:bg-sky-500/20";
|
||||
statusLabel = "Ingresado";
|
||||
break;
|
||||
case "diagnostico":
|
||||
colorClass = "bg-amber-500/10 text-amber-400 border-amber-500/25 hover:bg-amber-500/20";
|
||||
statusLabel = "En Diagnóstico";
|
||||
break;
|
||||
case "presupuestado":
|
||||
colorClass = "bg-purple-500/10 text-purple-400 border-purple-500/25 hover:bg-purple-500/20";
|
||||
statusLabel = "Presupuestado";
|
||||
break;
|
||||
case "reparacion":
|
||||
colorClass = "bg-blue-500/10 text-blue-400 border-blue-500/25 hover:bg-blue-500/20";
|
||||
statusLabel = "En Reparación";
|
||||
break;
|
||||
case "listo":
|
||||
colorClass = "bg-emerald-500/10 text-emerald-400 border-emerald-500/25 hover:bg-emerald-500/20";
|
||||
statusLabel = "Listo";
|
||||
break;
|
||||
default:
|
||||
colorClass = "bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80";
|
||||
statusLabel = "Entregado";
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-8 border px-2.5 text-xs font-semibold rounded-md flex items-center gap-1.5 cursor-pointer transition-colors shadow-sm select-none ${colorClass}`}
|
||||
>
|
||||
{statusLabel}
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-card border-border/80 text-foreground w-44 shadow-xl p-1.5 space-y-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "ingresado")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-sky-500/10 text-sky-400 border-sky-500/15 hover:bg-sky-500/20 hover:text-sky-350 focus:bg-sky-500/20 focus:text-sky-350"
|
||||
>
|
||||
Ingresado
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "diagnostico")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-amber-500/10 text-amber-400 border-amber-500/15 hover:bg-amber-500/20 hover:text-amber-350 focus:bg-amber-500/20 focus:text-amber-350"
|
||||
>
|
||||
En Diagnóstico
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "presupuestado")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-purple-500/10 text-purple-400 border-purple-500/15 hover:bg-purple-500/20 hover:text-purple-350 focus:bg-purple-500/20 focus:text-purple-350"
|
||||
>
|
||||
Presupuestado
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "reparacion")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-blue-500/10 text-blue-400 border-blue-500/15 hover:bg-blue-500/20 hover:text-blue-350 focus:bg-blue-500/20 focus:text-blue-350"
|
||||
>
|
||||
En Reparación
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "listo")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-emerald-500/10 text-emerald-400 border-emerald-500/15 hover:bg-emerald-500/20 hover:text-emerald-350 focus:bg-emerald-500/20 focus:text-emerald-350"
|
||||
>
|
||||
Listo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange(orderId, "entregado")}
|
||||
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80 hover:text-zinc-350 focus:bg-zinc-800/80 focus:text-zinc-350"
|
||||
>
|
||||
Entregado
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Cabecera interna */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Órdenes de Servicio
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-light">
|
||||
Alta de dispositivos ingresados y control de trazabilidad técnica del taller.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selector de Pestañas (Tabs) estilo shadcn */}
|
||||
<div className="flex border-b border-border/60 gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("registro")}
|
||||
className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
|
||||
activeTab === "registro"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Registrar Ingreso
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("control")}
|
||||
className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
|
||||
activeTab === "control"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Control de Trazabilidad y Estados
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="animate-fade-in pt-2">
|
||||
{activeTab === "registro" ? (
|
||||
<div className="max-w-2xl mx-auto w-full">
|
||||
{/* Formulario de Ingreso de Dispositivo (Card shadcn) */}
|
||||
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm space-y-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="font-outfit text-lg font-bold text-foreground flex items-center gap-2 border-0 pb-0">
|
||||
<PlusCircle className="w-5 h-5 text-primary" />
|
||||
Ingreso de Equipo
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Registrá los datos del cliente y el dispositivo a reparar.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegisterOrder} className="space-y-4">
|
||||
|
||||
{/* Sección: Datos del Cliente */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">1. Datos del Cliente</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
type="text"
|
||||
required
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="Edgar Ortiz"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-dni" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">DNI / Identificación</Label>
|
||||
<Input
|
||||
id="client-dni"
|
||||
type="text"
|
||||
required
|
||||
value={clientDni}
|
||||
onChange={(e) => setClientDni(e.target.value)}
|
||||
placeholder="44.375.912"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono</Label>
|
||||
<Input
|
||||
id="client-phone"
|
||||
type="text"
|
||||
required
|
||||
value={clientPhone}
|
||||
onChange={(e) => setClientPhone(e.target.value)}
|
||||
placeholder="+54 381 1234567"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email</Label>
|
||||
<Input
|
||||
id="client-email"
|
||||
type="email"
|
||||
required
|
||||
value={clientEmail}
|
||||
onChange={(e) => setClientEmail(e.target.value)}
|
||||
placeholder="edgar@repairit.cloud"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="border-border/60" />
|
||||
|
||||
{/* Sección: Datos del Equipo */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">2. Datos del Equipo</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="device-type" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Tipo</Label>
|
||||
<select
|
||||
id="device-type"
|
||||
value={deviceType}
|
||||
onChange={(e) => setDeviceType(e.target.value)}
|
||||
className="w-full h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="Notebook">Notebook</option>
|
||||
<option value="PC Escritorio">PC Escritorio</option>
|
||||
<option value="Consola">Consola</option>
|
||||
<option value="Celular">Celular</option>
|
||||
<option value="Otro">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="device-serial" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Número de Serie</Label>
|
||||
<Input
|
||||
id="device-serial"
|
||||
type="text"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.target.value)}
|
||||
placeholder="SN-XXXXX"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="device-model" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Marca y Modelo</Label>
|
||||
<Input
|
||||
id="device-model"
|
||||
type="text"
|
||||
required
|
||||
value={deviceModel}
|
||||
onChange={(e) => setDeviceModel(e.target.value)}
|
||||
placeholder="ASUS ROG Strix G15"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="device-issue" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Falla Reportada</Label>
|
||||
<Textarea
|
||||
id="device-issue"
|
||||
required
|
||||
rows={3}
|
||||
value={issue}
|
||||
onChange={(e) => setIssue(e.target.value)}
|
||||
placeholder="Describí los síntomas del dispositivo..."
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkbox de Recepción */}
|
||||
<div className="flex items-start space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={acceptTerms}
|
||||
onCheckedChange={(checked) => setAcceptTerms(checked)}
|
||||
className="border-border focus-visible:ring-primary"
|
||||
/>
|
||||
<div className="grid gap-1 leading-none">
|
||||
<Label
|
||||
htmlFor="terms"
|
||||
className="text-[10px] text-muted-foreground font-light leading-tight select-none cursor-pointer"
|
||||
>
|
||||
Confirmo la recepción física del equipo y autorizo la apertura del dispositivo para su diagnóstico.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 rounded-md transition-all select-none cursor-pointer mt-2"
|
||||
>
|
||||
Registrar Ingreso
|
||||
</Button>
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
/* Listado y Control de Órdenes Registradas (Card shadcn) */
|
||||
<Card className="bg-card/20 border-border p-6 shadow-sm flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center gap-2 border-0 pb-0">
|
||||
<ClipboardList className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
Control de Trazabilidad
|
||||
</CardTitle>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono font-bold">Total: {orders.length} Equipos</span>
|
||||
</div>
|
||||
|
||||
{orders.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
|
||||
<th className="py-3 px-2">Código</th>
|
||||
<th className="py-3 px-2">Cliente</th>
|
||||
<th className="py-3 px-2">Dispositivo</th>
|
||||
<th className="py-3 px-2">Fecha</th>
|
||||
<th className="py-3 px-2">Estado (Editar)</th>
|
||||
<th className="py-3 px-2 text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
|
||||
<td className="py-3.5 px-2 font-mono font-bold text-primary">{o.id}</td>
|
||||
<td className="py-3.5 px-2">
|
||||
<span className="font-medium block">{o.clientName}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{o.clientDni}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-2">
|
||||
<span className="font-medium block">{o.deviceModel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{o.deviceType}</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-2 text-muted-foreground">{o.date}</td>
|
||||
<td className="py-3.5 px-2">{getStatusDropdown(o.id, o.status)}</td>
|
||||
<td className="py-3.5 px-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDeleteOrder(o.id)}
|
||||
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/15"
|
||||
title="Eliminar de la lista de prueba"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 space-y-2">
|
||||
<p className="text-xs text-muted-foreground leading-normal font-light">
|
||||
No hay órdenes de servicio cargadas. Ve a la pestaña de "Registrar Ingreso" para añadir un ingreso técnico.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-muted-foreground/80 mt-6 border-t border-border/40 pt-3">
|
||||
* Nota: Los registros agregados persisten únicamente en el estado local de la sesión de prueba de React.
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/src/views/admin/ProfilePage.jsx
Normal file
159
frontend/src/views/admin/ProfilePage.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Store,
|
||||
Save,
|
||||
CheckCircle
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [venueName, setVenueName] = useState("RepairIT Tucumán Centro");
|
||||
const [address, setAddress] = useState("San Martín 650, San Miguel de Tucumán");
|
||||
const [phone, setPhone] = useState("+54 381 4223344");
|
||||
const [email, setEmail] = useState("tucuman@repairit.cloud");
|
||||
|
||||
const handleSave = (e) => {
|
||||
e.preventDefault();
|
||||
toast.success("¡Configuración del Taller Guardada!", {
|
||||
description: `Los datos de la sucursal "${venueName}" han sido actualizados con éxito.`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Cabecera interna */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2">
|
||||
<Store className="w-8 h-8 text-primary" />
|
||||
Perfil de Taller (Venue)
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-light">
|
||||
Gestión del perfil del local y estado de la suscripción de la sucursal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Columna Izquierda (2/3): Datos principales del taller */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
{/* Card 1: Datos de la Sucursal */}
|
||||
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader className="p-0 pb-5 border-b border-border/50">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground flex items-center gap-2 border-0 pb-0">
|
||||
Datos del Establecimiento
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Información de contacto que se mostrará en los recibos y el seguimiento del cliente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 pt-6">
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre del Taller</Label>
|
||||
<Input
|
||||
id="venue-name"
|
||||
type="text"
|
||||
required
|
||||
value={venueName}
|
||||
onChange={(e) => setVenueName(e.target.value)}
|
||||
placeholder="RepairIT Tucumán Centro"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email de Contacto</Label>
|
||||
<Input
|
||||
id="venue-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tucuman@repairit.cloud"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono Comercial</Label>
|
||||
<Input
|
||||
id="venue-phone"
|
||||
type="text"
|
||||
required
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+54 381 4223344"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="venue-address" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Dirección Física</Label>
|
||||
<Input
|
||||
id="venue-address"
|
||||
type="text"
|
||||
required
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="San Martín 650, San Miguel de Tucumán"
|
||||
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-6 rounded-md transition-all cursor-pointer flex items-center gap-2 select-none"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Columna Derecha (1/3): Suscripción */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Card 2: Suscripción */}
|
||||
<Card className="bg-card/25 border-border p-6 shadow-sm space-y-4">
|
||||
<h3 className="font-outfit text-base font-bold text-foreground border-b border-border pb-3 flex items-center gap-2">
|
||||
Suscripción
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3.5 text-xs font-light">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Plan Licenciado</span>
|
||||
<Badge className="bg-primary hover:bg-primary text-primary-foreground text-[10px] font-extrabold uppercase">Multi-Taller Pro</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
|
||||
<span className="text-muted-foreground">Vencimiento</span>
|
||||
<span className="text-foreground font-mono font-medium">31/12/2026</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
|
||||
<span className="text-muted-foreground">Estado del Pago</span>
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 fill-emerald-400/10" />
|
||||
Al Día
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
frontend/src/views/public/LandingPage.jsx
Normal file
487
frontend/src/views/public/LandingPage.jsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LandingPage() {
|
||||
const [trackingId, setTrackingId] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 640);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Estado del formulario de contacto
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [contactMessage, setContactMessage] = useState("");
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (trackingId.trim()) {
|
||||
navigate(`/seguimiento/${trackingId.trim()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContactSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!acceptTerms) {
|
||||
toast.error("Debe aceptar los términos de privacidad.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulación del envío de mensaje de contacto
|
||||
toast.success("¡Mensaje enviado con éxito!", {
|
||||
description: `Gracias por contactarte, ${contactName}. Te responderemos a la brevedad.`,
|
||||
});
|
||||
|
||||
// Resetear formulario
|
||||
setContactName("");
|
||||
setContactEmail("");
|
||||
setContactMessage("");
|
||||
setAcceptTerms(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-grow flex flex-col">
|
||||
{/* Sección Hero sin cuadrícula y sin tag de servicio */}
|
||||
<section className="relative pt-20 pb-24 px-6 flex flex-col items-center justify-center text-center overflow-hidden min-h-[80vh]">
|
||||
{/* Glow Central */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[350px] bg-primary/10 rounded-full blur-[100px] pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 max-w-3xl mx-auto space-y-8">
|
||||
|
||||
{/* Título Principal */}
|
||||
<h1 className="font-outfit text-5xl md:text-6xl font-extrabold text-foreground tracking-tight leading-[1.1]">
|
||||
Tu taller de servicio técnico, más{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary/60">
|
||||
transparente
|
||||
</span>{" "}
|
||||
que nunca
|
||||
</h1>
|
||||
|
||||
{/* Subtítulo */}
|
||||
<p className="text-muted-foreground text-base md:text-lg max-w-xl mx-auto font-light leading-relaxed">
|
||||
Hacemos el seguimiento de tu reparación simple. Consultá el estado de tu equipo en tiempo real y aprobá presupuestos con un solo clic, sin contraseñas.
|
||||
</p>
|
||||
|
||||
{/* Buscador de NanoID (CTA) - Optimizado para móviles */}
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="max-w-md mx-auto flex flex-row gap-2 bg-card/60 p-2 rounded-xl border border-border/80 shadow-2xl backdrop-blur w-full"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={isMobile ? "Código (ej. RT-2026)" : "Código de seguimiento (ej. RT-2026)"}
|
||||
value={trackingId}
|
||||
onChange={(e) => setTrackingId(e.target.value)}
|
||||
className="flex-grow bg-transparent border-0 border-none px-2 sm:px-4 py-3 text-sm text-foreground placeholder-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 min-w-0"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold px-4 sm:px-6 py-3 cursor-pointer select-none shrink-0"
|
||||
>
|
||||
<span className="hidden sm:inline">Consultar Estado</span>
|
||||
<span className="sm:hidden">Consultar</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Ejemplo rápido para testing */}
|
||||
<div className="text-xs text-muted-foreground font-medium">
|
||||
¿Querés probar? Ingresá <span onClick={() => setTrackingId("demo-id")} className="text-primary hover:underline cursor-pointer font-bold">demo-id</span> para ver una reparación simulada.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sección de Características */}
|
||||
<section id="caracteristicas" className="py-20 px-6 bg-background border-t border-border/50 relative z-10">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
|
||||
<div className="text-center max-w-2xl mx-auto space-y-3">
|
||||
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
¿Por qué elegir RepairIT?
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm font-light">
|
||||
Nuestra plataforma está diseñada para ofrecerte una visión transparente en cada etapa de la reparación.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4">
|
||||
|
||||
{/* Feature 1 con Card de shadcn */}
|
||||
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Trazabilidad Instantánea</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm font-light leading-relaxed">
|
||||
Olvidate de las llamadas telefónicas. A través de tu enlace único podés ver si tu equipo está ingresado, diagnosticado o listo para retirar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 2 con Card de shadcn */}
|
||||
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Aprobación Online</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm font-light leading-relaxed">
|
||||
Visualizá el presupuesto de mano de obra y repuestos en detalle. Podés aprobar o rechazar la reparación desde tu misma interfaz de consulta.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 3 con Card de shadcn */}
|
||||
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Control de Insumos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm font-light leading-relaxed">
|
||||
El sistema audita el stock técnico y descuenta automáticamente los componentes utilizados, asegurando que siempre tengamos repuestos disponibles.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sección de Precios */}
|
||||
<section id="precios" className="py-20 px-6 bg-card/10 border-t border-border/50 relative z-10">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
|
||||
<div className="text-center max-w-2xl mx-auto space-y-3">
|
||||
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Planes a tu Medida
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm font-light">
|
||||
Escogé el nivel de potencia y gestión que tu taller necesita para crecer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4 items-stretch">
|
||||
|
||||
{/* Tier 1: Inicial */}
|
||||
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/45 border-border/60 hover:border-border transition-all duration-200 hover:-translate-y-1">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="font-outfit text-xl font-bold text-foreground">Plan Inicial</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground font-light">Ideal para técnicos independientes que comienzan.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 flex-grow">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
|
||||
<span className="text-muted-foreground text-xs font-light">/ mes</span>
|
||||
</div>
|
||||
<div className="border-t border-border/40 my-4" />
|
||||
<ul className="space-y-3 text-xs text-muted-foreground font-light">
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Sed do eiusmod tempor incididunt ut labore.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Ut enim ad minim veniam, quis nostrud.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-4 border-t border-border/30 bg-muted/20">
|
||||
<Button className="w-full bg-secondary hover:bg-secondary/90 text-secondary-foreground font-semibold py-2 rounded-lg cursor-pointer" variant="outline">
|
||||
Comenzar gratis
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Tier 2: Taller Pro (Highlighted/Popular) */}
|
||||
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/60 border-primary/50 hover:border-primary transition-all duration-200 hover:-translate-y-1 relative ring-1 ring-primary/20">
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<Badge className="bg-primary hover:bg-primary text-primary-foreground font-semibold text-[10px] uppercase tracking-wider px-2.5 py-0.5">
|
||||
Más Popular
|
||||
</Badge>
|
||||
</div>
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="font-outfit text-xl font-bold text-foreground">Taller Pro</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground font-light">Para talleres medianos con flujo constante de clientes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 flex-grow">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
|
||||
<span className="text-muted-foreground text-xs font-light">/ mes</span>
|
||||
</div>
|
||||
<div className="border-t border-border/40 my-4" />
|
||||
<ul className="space-y-3 text-xs text-muted-foreground font-light">
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Duis aute irure dolor in reprehenderit in voluptate.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Excepteur sint occaecat cupidatat non proident.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Sunt in culpa qui officia deserunt mollit.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-4 border-t border-border/30 bg-primary/5">
|
||||
<Button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold py-2 rounded-lg cursor-pointer">
|
||||
Suscribirse Ahora
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Tier 3: Multi-Sucursal */}
|
||||
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/45 border-border/60 hover:border-border transition-all duration-200 hover:-translate-y-1">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="font-outfit text-xl font-bold text-foreground">Multi-Sucursal</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground font-light">Para redes de talleres o franquicias de soporte técnico.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 flex-grow">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
|
||||
<span className="text-muted-foreground text-xs font-light">/ mes</span>
|
||||
</div>
|
||||
<div className="border-t border-border/40 my-4" />
|
||||
<ul className="space-y-3 text-xs text-muted-foreground font-light">
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Velit esse cillum dolore eu fugiat nulla pariatur.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2.5">
|
||||
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Mollis pretium lorem primis iaculis cubilia.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-4 border-t border-border/30 bg-muted/20">
|
||||
<Button className="w-full bg-secondary hover:bg-secondary/90 text-secondary-foreground font-semibold py-2 rounded-lg cursor-pointer" variant="outline">
|
||||
Contactar Ventas
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sección de Preguntas Frecuentes (FAQ) */}
|
||||
<section id="faq" className="py-20 px-6 bg-background border-t border-border/50 relative z-10">
|
||||
<div className="max-w-3xl mx-auto space-y-12">
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Preguntas Frecuentes
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm font-light">
|
||||
Todo lo que necesitás saber sobre RepairIT y nuestro sistema de trazabilidad.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Accordion type="single" collapsible className="w-full space-y-4">
|
||||
<AccordionItem value="item-1" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
|
||||
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
|
||||
¿Cómo funciona el seguimiento de mi reparación?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
|
||||
Vas a recibir un código de seguimiento único al registrar tu equipo en nuestro taller. Solo tenes que ingresarlo en el buscador de la página de inicio para ver el estado de tu equipo en tiempo real, sin necesidad de crear una cuenta o recordar contraseñas.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
|
||||
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
|
||||
¿Puedo aprobar presupuestos en línea?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
|
||||
Sí. Cuando el técnico realice el diagnóstico y cargue el presupuesto de mano de obra e insumos, podrás visualizar el detalle en tu panel de seguimiento y aprobar o rechazar la reparación de forma digital con un solo clic.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
|
||||
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
|
||||
¿Tienen cargos ocultos adicionales?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
|
||||
No, para nada. La transparencia es nuestro pilar fundamental. Desglosamos cada centavo en el presupuesto de repuestos y mano de obra. Los planes de suscripción de taller también están fijados sin sorpresas ni letra chica.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
|
||||
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
|
||||
¿Mis datos personales y del dispositivo están seguros?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
|
||||
Absolutamente. Cumplimos rigurosamente con las normativas locales de protección de datos personales. Toda tu información y la de tus equipos está cifrada y almacenada en servidores de alta seguridad.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
|
||||
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
|
||||
¿Qué sucede si tengo más de una sucursal o taller?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
|
||||
Disponemos de un plan Multi-Sucursal especialmente diseñado para redes de soporte técnico. Te permite gestionar múltiples tenants o locaciones con perfiles independientes pero administrados centralmente.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sección de Contacto e Integración Typography */}
|
||||
<section id="contacto" className="py-20 px-6 bg-card/10 border-t border-border/50 relative z-10">
|
||||
<div className="max-w-2xl mx-auto space-y-10">
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Ponete en Contacto
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm font-light">
|
||||
¿Tenés alguna consulta técnica o comercial sobre nuestro servicio? Escribinos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/60 border-border p-6 shadow-xl backdrop-blur-md">
|
||||
<form onSubmit={handleContactSubmit} className="space-y-4">
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="name" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
placeholder="Edgar Ortiz"
|
||||
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="email" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Email</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
placeholder="edgar@repairit.cloud"
|
||||
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="message" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Mensaje / Consulta</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
required
|
||||
rows={4}
|
||||
value={contactMessage}
|
||||
onChange={(e) => setContactMessage(e.target.value)}
|
||||
placeholder="Detallá tu consulta aquí..."
|
||||
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checkbox de Privacidad */}
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="privacy"
|
||||
checked={acceptTerms}
|
||||
onCheckedChange={(checked) => setAcceptTerms(checked)}
|
||||
className="mt-0.5 border-border focus-visible:ring-primary"
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="privacy"
|
||||
className="text-xs text-muted-foreground font-light leading-tight select-none cursor-pointer"
|
||||
>
|
||||
Acepto que mis datos sean tratados de acuerdo con las{" "}
|
||||
<span className="text-primary hover:underline font-semibold cursor-pointer">Políticas de Privacidad</span> y los{" "}
|
||||
<span className="text-primary hover:underline font-semibold cursor-pointer">Términos de Servicio</span> de RepairIT.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm py-2.5 rounded-lg transition-all mt-2 cursor-pointer select-none"
|
||||
>
|
||||
Enviar Consulta
|
||||
</Button>
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/views/public/PrivacyPage.jsx
Normal file
52
frontend/src/views/public/PrivacyPage.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="flex-1 max-w-3xl w-full mx-auto px-6 py-16 space-y-8 animate-fade-in">
|
||||
<div className="space-y-3">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider">
|
||||
← Volver al Inicio
|
||||
</Link>
|
||||
<h1 className="font-outfit text-4xl font-black text-foreground tracking-tight leading-none pt-2">
|
||||
Políticas de Privacidad
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
Última actualización: 9 de Junio de 2026
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<article className="prose prose-slate dark:prose-invert text-sm text-muted-foreground leading-relaxed space-y-6">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-outfit text-xl font-bold text-foreground">
|
||||
Lorem Ipsum Dolor
|
||||
</h2>
|
||||
<p>
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Ut enim ad minim veniam, quis nostrud.</li>
|
||||
<li>Ullamco laboris nisi ut aliquip ex ea commodo.</li>
|
||||
<li>Duis aute irure dolor in reprehenderit in voluptate.</li>
|
||||
<li>Velit esse cillum dolore eu fugiat nulla pariatur.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-outfit text-xl font-bold text-foreground">
|
||||
Consectetur Adipiscing
|
||||
</h2>
|
||||
<p>
|
||||
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus ac, scelerisque vitae, pede.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/views/public/TermsPage.jsx
Normal file
52
frontend/src/views/public/TermsPage.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="flex-1 max-w-3xl w-full mx-auto px-6 py-16 space-y-8 animate-fade-in">
|
||||
<div className="space-y-3">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider">
|
||||
← Volver al Inicio
|
||||
</Link>
|
||||
<h1 className="font-outfit text-4xl font-black text-foreground tracking-tight leading-none pt-2">
|
||||
Términos de Servicio
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
Última actualización: 9 de Junio de 2026
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<article className="prose prose-slate dark:prose-invert text-sm text-muted-foreground leading-relaxed space-y-6">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-outfit text-xl font-bold text-foreground">
|
||||
Lorem Ipsum Dolor
|
||||
</h2>
|
||||
<p>
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Ut enim ad minim veniam, quis nostrud.</li>
|
||||
<li>Ullamco laboris nisi ut aliquip ex ea commodo.</li>
|
||||
<li>Duis aute irure dolor in reprehenderit in voluptate.</li>
|
||||
<li>Velit esse cillum dolore eu fugiat nulla pariatur.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-outfit text-xl font-bold text-foreground">
|
||||
Consectetur Adipiscing
|
||||
</h2>
|
||||
<p>
|
||||
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus ac, scelerisque vitae, pede.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
434
frontend/src/views/public/TrackingPage.jsx
Normal file
434
frontend/src/views/public/TrackingPage.jsx
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Lista de estados en orden secuencial
|
||||
const STEPS = [
|
||||
{ key: "ingresado", label: "Ingresado", desc: "Equipo recibido" },
|
||||
{ key: "diagnostico", label: "En Diagnóstico", desc: "Evaluando fallas" },
|
||||
{ key: "presupuestado", label: "Presupuestado", desc: "Costo listo" },
|
||||
{ key: "reparacion", label: "En Reparación", desc: "Técnico trabajando" },
|
||||
{ key: "listo", label: "Listo para Entregar", desc: "Pruebas superadas" },
|
||||
{ key: "entregado", label: "Entregado", desc: "Retirado por cliente" }
|
||||
];
|
||||
|
||||
export default function TrackingPage() {
|
||||
const { id } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [order, setOrder] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Simular llamada a la API
|
||||
const timer = setTimeout(() => {
|
||||
if (id === "demo-id") {
|
||||
setOrder({
|
||||
id: "demo-id",
|
||||
nanoId: "RT-2026-ASUS",
|
||||
client: "Edgar Javier Ortiz",
|
||||
dni: "44375912",
|
||||
phone: "+54 381 1234567",
|
||||
email: "edgar.ortiz@gmail.com",
|
||||
device: "Notebook ASUS ROG Strix G15",
|
||||
serial: "SN-98218-ASUS",
|
||||
report: "Calentamiento excesivo (alcanza 95°C) y reinicios aleatorios durante sesiones de renderizado o juegos.",
|
||||
currentStatus: "listo", // Empezar en presupuestado para poder probar el flujo de aprobación
|
||||
dateIngress: "05/06/2026",
|
||||
budget: {
|
||||
items: [
|
||||
{ desc: "Mantenimiento térmico preventivo (limpieza + cambio de pasta térmica Arctic MX-6)", price: 15000 },
|
||||
{ desc: "Reemplazo de Cooler FAN original (lado izquierdo - fallando rodamientos)", price: 25000 }
|
||||
],
|
||||
approved: false, // Iniciar sin aprobar para el testing interactivo
|
||||
dateApproved: null
|
||||
},
|
||||
history: [
|
||||
{ date: "05/06/2026 10:30", text: "Equipo ingresado al sistema por recepción física." },
|
||||
{ date: "06/06/2026 14:15", text: "Diagnóstico finalizado. Se detectó cooler izquierdo trabado y pasta térmica original cristalizada." }
|
||||
]
|
||||
});
|
||||
} else {
|
||||
// Orden genérica para cualquier otro ID
|
||||
setOrder({
|
||||
id: id,
|
||||
nanoId: id.toUpperCase(),
|
||||
client: "Cliente Registrado",
|
||||
dni: "29.987.654",
|
||||
phone: "+54 381 4567890",
|
||||
email: "cliente.registrado@email.com",
|
||||
device: "Dispositivo Tecnológico Genérico",
|
||||
serial: "SN-PENDIENTE",
|
||||
report: "Diagnóstico inicial pendiente de revisión.",
|
||||
currentStatus: "diagnostico",
|
||||
dateIngress: "08/06/2026",
|
||||
budget: null,
|
||||
history: [
|
||||
{ date: "08/06/2026 08:00", text: "Equipo ingresado. En espera de evaluación del departamento técnico." }
|
||||
]
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}, 1200); // Demora para visualizar el skeleton de carga
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [id]);
|
||||
|
||||
// Manejo de la aprobación del presupuesto en el cliente
|
||||
const handleApproveBudget = () => {
|
||||
if (!order) return;
|
||||
|
||||
// Simular aprobación
|
||||
setOrder(prev => ({
|
||||
...prev,
|
||||
currentStatus: "reparacion", // Pasa a reparación al aprobar
|
||||
budget: {
|
||||
...prev.budget,
|
||||
approved: true,
|
||||
dateApproved: new Date().toLocaleDateString("es-AR")
|
||||
},
|
||||
history: [
|
||||
...prev.history,
|
||||
{
|
||||
date: new Date().toLocaleString("es-AR"),
|
||||
text: "Presupuesto aprobado de forma online por el cliente. Inicio de trabajos técnicos autorizado."
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
toast.success("¡Presupuesto Aprobado!", {
|
||||
description: "El departamento técnico ha sido notificado para iniciar la reparación."
|
||||
});
|
||||
};
|
||||
|
||||
// Obtener el índice del estado actual
|
||||
const currentStepIndex = order ? STEPS.findIndex(s => s.key === order.currentStatus) : 0;
|
||||
// Porcentaje del progreso (basado en el índice)
|
||||
const progressPercent = order ? (currentStepIndex / (STEPS.length - 1)) * 100 : 0;
|
||||
|
||||
// Renderizador de Skeleton de carga
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
|
||||
<div className="border-b border-border pb-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-96" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton de la Línea de Tiempo */}
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<Skeleton className="h-44 w-full rounded-2xl" />
|
||||
<Skeleton className="h-44 w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[380px] w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
|
||||
|
||||
{/* Cabecera del Seguimiento */}
|
||||
<div className="border-b border-border pb-6">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider flex items-center gap-1">
|
||||
← Volver
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-3" />
|
||||
<span className="text-primary text-xs font-bold uppercase tracking-wider">Orden de Servicio</span>
|
||||
</div>
|
||||
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
|
||||
Seguimiento de Equipo: <span className="text-muted-foreground">{order.nanoId}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LINEA DE TIEMPO DEL ESTADO CON PROGRESS BAR DE SHADCN */}
|
||||
<Card className="bg-card/20 border-border p-8 rounded-2xl relative overflow-hidden shadow-xl">
|
||||
<div className="relative flex flex-col gap-8 w-full">
|
||||
|
||||
<div className="relative flex flex-col md:flex-row justify-between items-start md:items-center gap-8 md:gap-0 w-full">
|
||||
{/* Componente Progress nativo de shadcn - Centrado al píxel */}
|
||||
<div className="absolute left-[calc(100%/12)] right-[calc(100%/12)] top-[12px] -translate-y-1/2 h-1 pointer-events-none z-0 hidden md:block">
|
||||
<Progress value={progressPercent} className="h-1 bg-border [&>div]:bg-primary" />
|
||||
</div>
|
||||
|
||||
{STEPS.map((step, idx) => {
|
||||
const isCompleted = idx <= currentStepIndex;
|
||||
const isActive = idx === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className="relative z-10 flex items-start md:flex-col md:items-center gap-4 md:gap-2.5 text-left md:text-center flex-1 w-full md:w-auto"
|
||||
>
|
||||
{/* Línea vertical de conexión en móvil */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className="absolute left-[11px] top-[24px] bottom-[-32px] w-[2px] bg-border z-0 md:hidden">
|
||||
<div className={`w-full bg-primary transition-all duration-300 ${idx < currentStepIndex ? "h-full" : "h-0"}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Círculo indicador */}
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center border-2 transition-all duration-300 shrink-0 ${isActive
|
||||
? "bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/30 scale-105"
|
||||
: isCompleted
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||
) : isCompleted ? (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Etiquetas */}
|
||||
<div className="space-y-0.5 pt-0.5 md:pt-0">
|
||||
<span className={`text-xs font-bold block ${isCompleted ? "text-foreground" : "text-muted-foreground"}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-light block leading-tight md:max-w-[125px] md:mx-auto">
|
||||
{step.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* DETALLES Y PRESUPUESTO */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Columna Izquierda (2/3): Datos del Cliente, Equipo e Historial */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
|
||||
{/* Datos del Cliente con Card de shadcn */}
|
||||
<Card className="bg-card/25 border-border shadow-md">
|
||||
<CardHeader className="border-b border-border/50 py-4">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground">
|
||||
Datos del Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Cliente</span>
|
||||
<span className="text-foreground text-sm mt-1 block font-medium">{order.client}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">DNI / Identificación</span>
|
||||
<span className="text-foreground text-sm mt-1 block font-mono">{order.dni}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Teléfono</span>
|
||||
<span className="text-foreground text-sm mt-1 block">{order.phone}</span>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Email</span>
|
||||
<span className="text-foreground text-sm mt-1 block">{order.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ficha Técnica con Card de shadcn */}
|
||||
<Card className="bg-card/25 border-border shadow-md">
|
||||
<CardHeader className="border-b border-border/50 py-4">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground">
|
||||
Detalles del Dispositivo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 gap-5 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Dispositivo</span>
|
||||
<span className="text-foreground text-sm mt-1 block font-medium">{order.device}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Número de Serie</span>
|
||||
<span className="text-foreground/90 text-sm mt-1 block font-mono font-bold">{order.serial}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Falla Reportada</span>
|
||||
<p className="text-foreground/90 text-sm mt-2 leading-relaxed font-light bg-background/50 p-3.5 rounded-lg border border-border/40">
|
||||
{order.report}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Historial de Trazabilidad con Card de shadcn */}
|
||||
<Card className="bg-card/25 border-border shadow-md">
|
||||
<CardHeader className="border-b border-border/50 py-4">
|
||||
<CardTitle className="font-outfit text-base font-bold text-foreground">
|
||||
Historial del Servicio
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6 relative before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-[1px] before:bg-border">
|
||||
{order.history.map((log, index) => (
|
||||
<div key={index} className="flex gap-4 relative z-10 text-xs">
|
||||
<div className="w-[23px] h-[23px] rounded-full bg-background border border-border flex items-center justify-center shrink-0 shadow-sm">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground font-mono font-medium block">{log.date}</span>
|
||||
<p className="text-foreground/95 text-sm font-light leading-relaxed">{log.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Columna Derecha (1/3): Presupuesto y Acciones */}
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Ficha de Presupuesto con Card de shadcn */}
|
||||
<Card className="bg-card/30 border-border p-6 shadow-xl flex flex-col justify-between h-full">
|
||||
<div className="space-y-5">
|
||||
<h3 className="font-outfit text-lg font-bold text-foreground border-b border-border pb-2.5">
|
||||
Presupuesto del Servicio
|
||||
</h3>
|
||||
|
||||
{order.budget ? (
|
||||
<div className="space-y-5">
|
||||
{/* Desglose */}
|
||||
<div className="space-y-3.5">
|
||||
{order.budget.items.map((item, idx) => (
|
||||
<div key={idx} className="text-xs space-y-1 border-b border-border/40 pb-2">
|
||||
<p className="text-foreground/95 leading-relaxed font-light">{item.desc}</p>
|
||||
<span className="text-primary font-bold block">${item.price.toLocaleString("es-AR")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase">Total Estimado</span>
|
||||
<span className="text-xl font-outfit font-extrabold text-foreground">
|
||||
${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Estado de Aprobación mediante Alert de shadcn */}
|
||||
{order.budget.approved ? (
|
||||
<Alert className="bg-emerald-950/20 border-emerald-500/25 text-emerald-400 p-3 shadow-sm">
|
||||
<svg className="w-4 h-4 shrink-0 stroke-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<AlertTitle className="text-xs font-bold font-outfit ml-2">Aprobado</AlertTitle>
|
||||
<AlertDescription className="text-[10px] opacity-90 ml-2">
|
||||
Autorizado el {order.budget.dateApproved}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-3 pt-2">
|
||||
<Alert variant="destructive" className="bg-amber-950/20 border-amber-500/30 text-amber-400 p-3 shadow-sm">
|
||||
<svg className="w-4 h-4 stroke-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<AlertTitle className="text-xs font-bold font-outfit ml-2">Espera de Aprobación</AlertTitle>
|
||||
<AlertDescription className="text-[10px] opacity-90 ml-2">
|
||||
Requiere autorización del cliente para iniciar tareas.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Modal de Confirmación AlertDialog para Aprobar */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer">
|
||||
Aprobar Presupuesto
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-card border-border shadow-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="font-outfit text-lg font-bold text-foreground">
|
||||
¿Confirmar aprobación del presupuesto?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-muted-foreground text-xs font-light">
|
||||
Al aprobar, autorizás al taller a realizar el recambio de cooler y mantenimiento térmico especificado por un total de <strong className="text-foreground font-bold">${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}</strong>.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-background border-border text-foreground hover:bg-muted font-bold text-xs">Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleApproveBudget} className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs">
|
||||
Confirmar Aprobación
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed font-light">
|
||||
El técnico aún no ha cargado el presupuesto detallado de mano de obra y repuestos.
|
||||
</p>
|
||||
<Badge variant="secondary" className="px-3 py-1 font-bold text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Pendiente de Carga
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="pt-4 border-t border-border mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => toast.info("Módulo de exportación PDF disponible en la Fase VI.")}
|
||||
className="w-full bg-background hover:bg-muted text-foreground border-border text-xs font-bold py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center gap-2 select-none"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Exportar Presupuesto (PDF)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from "path"
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react()
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user