feat: Initial commit - Mexus App
Sistema de Gestión de Obras de Construcción completo con: - Dashboard con KPIs y gráficos - Módulo de obras con fases y tareas - Control financiero (gastos, presupuestos) - Gestión de recursos (personal, subcontratistas) - Inventario de materiales con alertas de stock - Reportes con exportación CSV - Autenticación con roles (NextAuth.js v5) - API REST completa - Documentación de API y base de datos - Configuración Docker para despliegue Stack: Next.js 14+, TypeScript, Tailwind CSS, Prisma, PostgreSQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Production
|
||||||
|
build
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Base de Datos
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/construccion_db?schema=public"
|
||||||
|
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
||||||
|
|
||||||
|
# Node Environment
|
||||||
|
NODE_ENV="development"
|
||||||
12
.env.production.example
Normal file
12
.env.production.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_USER=construccion_user
|
||||||
|
POSTGRES_PASSWORD=your-secure-password-here
|
||||||
|
POSTGRES_DB=construccion_db
|
||||||
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public"
|
||||||
|
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_URL=https://yourdomain.com
|
||||||
|
NEXTAUTH_SECRET=generate-a-secure-secret-with-openssl-rand-base64-32
|
||||||
|
|
||||||
|
# Node
|
||||||
|
NODE_ENV=production
|
||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/*.sql.bak
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma Client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy Prisma files
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
241
README.md
Normal file
241
README.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Mexus App - Sistema de Gestión de Obras de Construcción
|
||||||
|
|
||||||
|
Sistema integral para la gestión de obras de construcción, desarrollado con Next.js 14+, diseñado para empresas constructoras que necesitan administrar proyectos, finanzas, recursos e inventario.
|
||||||
|
|
||||||
|
## Características Principales
|
||||||
|
|
||||||
|
### Módulos del Sistema
|
||||||
|
|
||||||
|
| Módulo | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Dashboard** | Panel principal con KPIs, gráficos de avance y resumen financiero |
|
||||||
|
| **Obras** | Gestión completa de proyectos de construcción con fases y tareas |
|
||||||
|
| **Finanzas** | Control de gastos, presupuestos y flujo de caja |
|
||||||
|
| **Recursos** | Administración de personal y subcontratistas |
|
||||||
|
| **Inventario** | Control de materiales con alertas de stock bajo |
|
||||||
|
| **Reportes** | Generación de reportes con exportación a CSV |
|
||||||
|
|
||||||
|
### Funcionalidades Destacadas
|
||||||
|
|
||||||
|
- **Autenticación segura** con NextAuth.js v5
|
||||||
|
- **Control de acceso por roles** (Admin, Gerente, Supervisor, Contador, Empleado)
|
||||||
|
- **Multi-empresa** - Cada usuario pertenece a una empresa
|
||||||
|
- **Dashboard interactivo** con gráficos Recharts
|
||||||
|
- **Alertas automáticas** de stock bajo en inventario
|
||||||
|
- **Registro de movimientos** de inventario (entradas/salidas)
|
||||||
|
- **Responsive design** con Tailwind CSS y shadcn/ui
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Tecnología | Versión | Uso |
|
||||||
|
|------------|---------|-----|
|
||||||
|
| Next.js | 14.2.x | Framework React con App Router |
|
||||||
|
| TypeScript | 5.x | Tipado estático |
|
||||||
|
| Tailwind CSS | 3.4.x | Estilos utilitarios |
|
||||||
|
| shadcn/ui | - | Componentes UI |
|
||||||
|
| Prisma | 5.22.x | ORM para PostgreSQL |
|
||||||
|
| PostgreSQL | 15+ | Base de datos |
|
||||||
|
| NextAuth.js | 5.x (beta) | Autenticación |
|
||||||
|
| Recharts | 2.x | Gráficos y visualizaciones |
|
||||||
|
| Zod | 3.x | Validación de esquemas |
|
||||||
|
|
||||||
|
## Requisitos Previos
|
||||||
|
|
||||||
|
- Node.js 20.x o superior
|
||||||
|
- PostgreSQL 15 o superior
|
||||||
|
- npm o yarn
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### 1. Clonar el repositorio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.consultoria-as.com/tu-usuario/mexus-app.git
|
||||||
|
cd mexus-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar variables de entorno
|
||||||
|
|
||||||
|
Crear archivo `.env` en la raíz del proyecto:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Base de Datos
|
||||||
|
DATABASE_URL="postgresql://usuario:password@localhost:5432/construccion_db?schema=public"
|
||||||
|
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="tu-secret-key-segura-aqui"
|
||||||
|
|
||||||
|
# Node Environment
|
||||||
|
NODE_ENV="development"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configurar base de datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crear las tablas
|
||||||
|
npx prisma migrate dev
|
||||||
|
|
||||||
|
# Poblar con datos de ejemplo (opcional)
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Iniciar el servidor de desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
La aplicación estará disponible en `http://localhost:3000`
|
||||||
|
|
||||||
|
## Credenciales de Prueba
|
||||||
|
|
||||||
|
Si ejecutaste el seed, puedes usar estas credenciales:
|
||||||
|
|
||||||
|
| Email | Contraseña | Rol |
|
||||||
|
|-------|------------|-----|
|
||||||
|
| admin@demo.com | admin123 | ADMIN |
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
construccion-app/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Modelos de datos
|
||||||
|
│ └── seed.ts # Datos de ejemplo
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── (auth)/ # Páginas de autenticación
|
||||||
|
│ │ │ ├── login/
|
||||||
|
│ │ │ └── registro/
|
||||||
|
│ │ ├── (dashboard)/ # Área principal protegida
|
||||||
|
│ │ │ ├── dashboard/ # Panel principal
|
||||||
|
│ │ │ ├── obras/ # Gestión de obras
|
||||||
|
│ │ │ ├── finanzas/ # Control financiero
|
||||||
|
│ │ │ ├── recursos/ # Personal y recursos
|
||||||
|
│ │ │ │ ├── materiales/ # Inventario
|
||||||
|
│ │ │ │ └── personal/ # Empleados
|
||||||
|
│ │ │ └── reportes/ # Reportes
|
||||||
|
│ │ └── api/ # API Routes
|
||||||
|
│ │ ├── auth/ # NextAuth endpoints
|
||||||
|
│ │ ├── obras/ # CRUD obras
|
||||||
|
│ │ ├── gastos/ # CRUD gastos
|
||||||
|
│ │ └── materiales/ # CRUD materiales
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # Componentes shadcn/ui
|
||||||
|
│ │ └── layout/ # Sidebar, Header
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── auth.ts # Configuración NextAuth
|
||||||
|
│ │ ├── prisma.ts # Cliente Prisma
|
||||||
|
│ │ ├── utils.ts # Utilidades
|
||||||
|
│ │ └── validations.ts # Esquemas Zod
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ └── types/ # TypeScript types
|
||||||
|
├── docker-compose.yml # Configuración Docker
|
||||||
|
├── Dockerfile # Build de producción
|
||||||
|
└── nginx.conf # Configuración Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos Disponibles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev # Inicia servidor de desarrollo
|
||||||
|
npm run build # Compila para producción
|
||||||
|
npm run start # Inicia servidor de producción
|
||||||
|
npm run lint # Ejecuta ESLint
|
||||||
|
|
||||||
|
# Base de datos
|
||||||
|
npx prisma migrate dev # Ejecuta migraciones en desarrollo
|
||||||
|
npx prisma migrate deploy # Ejecuta migraciones en producción
|
||||||
|
npx prisma db seed # Ejecuta el seed
|
||||||
|
npx prisma studio # Abre el explorador de BD
|
||||||
|
npx prisma generate # Regenera el cliente Prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue con Docker
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
- `POST /api/auth/callback/credentials` - Login
|
||||||
|
- `GET /api/auth/session` - Obtener sesión actual
|
||||||
|
- `POST /api/auth/signout` - Cerrar sesión
|
||||||
|
|
||||||
|
### Obras
|
||||||
|
- `GET /api/obras` - Listar obras
|
||||||
|
- `POST /api/obras` - Crear obra
|
||||||
|
- `GET /api/obras/[id]` - Obtener obra
|
||||||
|
- `PUT /api/obras/[id]` - Actualizar obra
|
||||||
|
- `DELETE /api/obras/[id]` - Eliminar obra
|
||||||
|
|
||||||
|
### Gastos
|
||||||
|
- `GET /api/gastos` - Listar gastos
|
||||||
|
- `POST /api/gastos` - Crear gasto
|
||||||
|
- `PUT /api/gastos/[id]` - Actualizar gasto
|
||||||
|
- `DELETE /api/gastos/[id]` - Eliminar gasto
|
||||||
|
- `PATCH /api/gastos/[id]/aprobar` - Aprobar gasto
|
||||||
|
|
||||||
|
### Materiales
|
||||||
|
- `GET /api/materiales` - Listar materiales
|
||||||
|
- `POST /api/materiales` - Crear material
|
||||||
|
- `PUT /api/materiales/[id]` - Actualizar material
|
||||||
|
- `DELETE /api/materiales/[id]` - Eliminar material
|
||||||
|
- `POST /api/materiales/movimiento` - Registrar movimiento de inventario
|
||||||
|
|
||||||
|
## Roles y Permisos
|
||||||
|
|
||||||
|
| Rol | Descripción | Permisos |
|
||||||
|
|-----|-------------|----------|
|
||||||
|
| ADMIN | Administrador | Acceso completo |
|
||||||
|
| GERENTE | Gerente de proyectos | Gestión de obras y finanzas |
|
||||||
|
| SUPERVISOR | Supervisor de obra | Registro de avances y gastos |
|
||||||
|
| CONTADOR | Contador | Gestión financiera |
|
||||||
|
| EMPLEADO | Empleado general | Solo lectura |
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- Autenticación mediante JWT con NextAuth.js
|
||||||
|
- Middleware de protección de rutas
|
||||||
|
- Validación de datos con Zod en cliente y servidor
|
||||||
|
- Filtrado de datos por empresa (multi-tenant)
|
||||||
|
- Contraseñas hasheadas con bcrypt
|
||||||
|
- Headers de seguridad configurados
|
||||||
|
|
||||||
|
## Contribuir
|
||||||
|
|
||||||
|
1. Fork el repositorio
|
||||||
|
2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`)
|
||||||
|
3. Commit tus cambios (`git commit -m 'Agrega nueva funcionalidad'`)
|
||||||
|
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
|
||||||
|
5. Abre un Pull Request
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Este proyecto es privado y de uso interno.
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
Para soporte técnico, contactar al equipo de desarrollo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Desarrollado con Next.js y TypeScript
|
||||||
68
docker-compose.prod.yml
Normal file
68
docker-compose.prod.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||||
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./certs:/etc/nginx/certs:ro
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
construccion-network:
|
||||||
|
driver: bridge
|
||||||
76
docker-compose.yml
Normal file
76
docker-compose.yml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/construccion_db?schema=public
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-desarrollo-secret-key-cambiar-en-produccion}
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=construccion_db
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
|
||||||
|
# Database migrations runner
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: npx prisma migrate deploy
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/construccion_db?schema=public
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
|
||||||
|
# Optional: pgAdmin for database management
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:latest
|
||||||
|
environment:
|
||||||
|
- PGADMIN_DEFAULT_EMAIL=admin@admin.com
|
||||||
|
- PGADMIN_DEFAULT_PASSWORD=admin
|
||||||
|
ports:
|
||||||
|
- "5050:80"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- construccion-network
|
||||||
|
profiles:
|
||||||
|
- admin
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
construccion-network:
|
||||||
|
driver: bridge
|
||||||
527
docs/API.md
Normal file
527
docs/API.md
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
# Documentación de API - Mexus App
|
||||||
|
|
||||||
|
Esta documentación describe todos los endpoints disponibles en la API REST de Mexus App.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
Desarrollo: http://localhost:3000/api
|
||||||
|
Producción: https://tu-dominio.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
|
||||||
|
La API utiliza NextAuth.js para la autenticación. Las peticiones autenticadas requieren una cookie de sesión válida.
|
||||||
|
|
||||||
|
### Headers requeridos
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Cookie: authjs.session-token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Autenticación
|
||||||
|
|
||||||
|
### Obtener token CSRF
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/auth/csrf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"csrfToken": "0ee47c595b90ef7b0356521f5354695cfe22a5797fa30cecba0a279ce28a9719"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iniciar sesión
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/callback/credentials
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|-------|------|-----------|-------------|
|
||||||
|
| csrfToken | string | Sí | Token CSRF obtenido previamente |
|
||||||
|
| email | string | Sí | Correo electrónico del usuario |
|
||||||
|
| password | string | Sí | Contraseña del usuario |
|
||||||
|
|
||||||
|
**Respuesta exitosa (302):** Redirección a dashboard con cookie de sesión
|
||||||
|
|
||||||
|
### Obtener sesión actual
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": "cmkk6tf3y00025x5stdh7x2f9",
|
||||||
|
"email": "admin@demo.com",
|
||||||
|
"nombre": "Admin",
|
||||||
|
"apellido": "Demo",
|
||||||
|
"role": "ADMIN",
|
||||||
|
"empresaId": "cmkk6ter000005x5smacuuh5x"
|
||||||
|
},
|
||||||
|
"expires": "2026-01-20T00:25:19.994Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cerrar sesión
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/signout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Obras
|
||||||
|
|
||||||
|
### Listar obras
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/obras
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cmkk6tf4900085x5semza5bzu",
|
||||||
|
"codigo": "OBR-2024-001",
|
||||||
|
"nombre": "Edificio Residencial Aurora",
|
||||||
|
"descripcion": "Construcción de edificio de 8 niveles",
|
||||||
|
"cliente": "Inmobiliaria del Norte S.A.",
|
||||||
|
"ubicacion": "Col. Centro, Ciudad",
|
||||||
|
"estado": "EN_PROGRESO",
|
||||||
|
"presupuestoTotal": 15000000,
|
||||||
|
"fechaInicio": "2024-01-15T00:00:00.000Z",
|
||||||
|
"fechaFinEstimada": "2025-06-30T00:00:00.000Z",
|
||||||
|
"avanceGeneral": 35,
|
||||||
|
"_count": {
|
||||||
|
"fases": 4,
|
||||||
|
"gastos": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear obra
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/obras
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codigo": "OBR-2024-002",
|
||||||
|
"nombre": "Casa Habitación López",
|
||||||
|
"descripcion": "Construcción de casa de 2 niveles",
|
||||||
|
"cliente": "Juan López García",
|
||||||
|
"ubicacion": "Fracc. Las Palmas #123",
|
||||||
|
"presupuestoTotal": 2500000,
|
||||||
|
"fechaInicio": "2024-03-01",
|
||||||
|
"fechaFinEstimada": "2024-09-30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|-------|------|-----------|-------------|
|
||||||
|
| codigo | string | Sí | Código único de la obra |
|
||||||
|
| nombre | string | Sí | Nombre del proyecto |
|
||||||
|
| descripcion | string | No | Descripción detallada |
|
||||||
|
| cliente | string | Sí | Nombre del cliente |
|
||||||
|
| ubicacion | string | No | Dirección o ubicación |
|
||||||
|
| presupuestoTotal | number | Sí | Presupuesto en MXN |
|
||||||
|
| fechaInicio | date | Sí | Fecha de inicio |
|
||||||
|
| fechaFinEstimada | date | No | Fecha estimada de finalización |
|
||||||
|
|
||||||
|
**Respuesta exitosa (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cmkk7tf4900085x5semza5bzu",
|
||||||
|
"codigo": "OBR-2024-002",
|
||||||
|
"nombre": "Casa Habitación López",
|
||||||
|
"estado": "PLANEACION",
|
||||||
|
"avanceGeneral": 0,
|
||||||
|
"createdAt": "2024-01-20T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Obtener obra por ID
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/obras/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parámetros de URL:**
|
||||||
|
| Parámetro | Tipo | Descripción |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| id | string | ID de la obra |
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cmkk6tf4900085x5semza5bzu",
|
||||||
|
"codigo": "OBR-2024-001",
|
||||||
|
"nombre": "Edificio Residencial Aurora",
|
||||||
|
"descripcion": "Construcción de edificio de 8 niveles",
|
||||||
|
"estado": "EN_PROGRESO",
|
||||||
|
"presupuestoTotal": 15000000,
|
||||||
|
"avanceGeneral": 35,
|
||||||
|
"fases": [
|
||||||
|
{
|
||||||
|
"id": "fase1",
|
||||||
|
"nombre": "Cimentación",
|
||||||
|
"orden": 1,
|
||||||
|
"avance": 100,
|
||||||
|
"estado": "COMPLETADA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gastos": [
|
||||||
|
{
|
||||||
|
"id": "gasto1",
|
||||||
|
"concepto": "Compra de cemento",
|
||||||
|
"monto": 45000,
|
||||||
|
"estado": "APROBADO"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizar obra
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/obras/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:** (campos opcionales)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre": "Edificio Residencial Aurora - Fase 2",
|
||||||
|
"estado": "EN_PROGRESO",
|
||||||
|
"avanceGeneral": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):** Objeto obra actualizado
|
||||||
|
|
||||||
|
### Eliminar obra
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/obras/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Obra eliminada exitosamente"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Gastos
|
||||||
|
|
||||||
|
### Listar gastos
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/gastos
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
| Parámetro | Tipo | Descripción |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| obraId | string | Filtrar por obra específica |
|
||||||
|
| estado | string | Filtrar por estado (PENDIENTE, APROBADO, RECHAZADO) |
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cmkk6tf5r000k5x5sxwf04qji",
|
||||||
|
"concepto": "Compra de materiales",
|
||||||
|
"descripcion": "Cemento y varilla para cimentación",
|
||||||
|
"monto": 85000,
|
||||||
|
"categoria": "MATERIALES",
|
||||||
|
"estado": "APROBADO",
|
||||||
|
"fecha": "2024-01-20T00:00:00.000Z",
|
||||||
|
"comprobante": "FAC-12345",
|
||||||
|
"obra": {
|
||||||
|
"id": "obra1",
|
||||||
|
"nombre": "Edificio Aurora"
|
||||||
|
},
|
||||||
|
"usuario": {
|
||||||
|
"nombre": "Juan",
|
||||||
|
"apellido": "Pérez"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear gasto
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/gastos
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"concepto": "Pago de mano de obra",
|
||||||
|
"descripcion": "Pago semanal de albañiles",
|
||||||
|
"monto": 25000,
|
||||||
|
"categoria": "MANO_OBRA",
|
||||||
|
"fecha": "2024-01-20",
|
||||||
|
"obraId": "cmkk6tf4900085x5semza5bzu",
|
||||||
|
"comprobante": "REC-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|-------|------|-----------|-------------|
|
||||||
|
| concepto | string | Sí | Concepto del gasto |
|
||||||
|
| descripcion | string | No | Descripción detallada |
|
||||||
|
| monto | number | Sí | Monto en MXN |
|
||||||
|
| categoria | enum | Sí | MATERIALES, MANO_OBRA, SUBCONTRATO, EQUIPO, TRANSPORTE, ADMINISTRATIVO, OTROS |
|
||||||
|
| fecha | date | Sí | Fecha del gasto |
|
||||||
|
| obraId | string | Sí | ID de la obra asociada |
|
||||||
|
| comprobante | string | No | Número de factura/recibo |
|
||||||
|
|
||||||
|
**Respuesta exitosa (201):** Objeto gasto creado
|
||||||
|
|
||||||
|
### Aprobar gasto
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/gastos/{id}/aprobar
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"estado": "APROBADO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valores permitidos para estado:** `APROBADO`, `RECHAZADO`
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):** Objeto gasto actualizado
|
||||||
|
|
||||||
|
### Eliminar gasto
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/gastos/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Gasto eliminado exitosamente"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints de Materiales
|
||||||
|
|
||||||
|
### Listar materiales
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/materiales
|
||||||
|
```
|
||||||
|
|
||||||
|
**Respuesta exitosa (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cmkk6tf6f000x5x5s413dejgf",
|
||||||
|
"codigo": "CEM-001",
|
||||||
|
"nombre": "Cemento Portland",
|
||||||
|
"descripcion": "Cemento gris tipo I",
|
||||||
|
"unidad": "BOLSA",
|
||||||
|
"precioUnitario": 180,
|
||||||
|
"stockMinimo": 100,
|
||||||
|
"stockActual": 250,
|
||||||
|
"ubicacion": "Bodega A",
|
||||||
|
"activo": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear material
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/materiales
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codigo": "VAR-002",
|
||||||
|
"nombre": "Varilla corrugada 1/2",
|
||||||
|
"descripcion": "Varilla de acero corrugado",
|
||||||
|
"unidad": "PIEZA",
|
||||||
|
"precioUnitario": 180,
|
||||||
|
"stockMinimo": 200,
|
||||||
|
"ubicacion": "Bodega B"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|-------|------|-----------|-------------|
|
||||||
|
| codigo | string | Sí | Código único del material |
|
||||||
|
| nombre | string | Sí | Nombre del material |
|
||||||
|
| descripcion | string | No | Descripción |
|
||||||
|
| unidad | enum | Sí | PIEZA, METRO, METRO_CUADRADO, METRO_CUBICO, KILOGRAMO, LITRO, BOLSA, BULTO, ROLLO, CAJA, UNIDAD |
|
||||||
|
| precioUnitario | number | Sí | Precio por unidad |
|
||||||
|
| stockMinimo | number | No | Stock mínimo para alertas |
|
||||||
|
| ubicacion | string | No | Ubicación en bodega |
|
||||||
|
|
||||||
|
**Respuesta exitosa (201):** Objeto material creado
|
||||||
|
|
||||||
|
### Actualizar material
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/materiales/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:** (campos opcionales)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"precioUnitario": 195,
|
||||||
|
"stockMinimo": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eliminar material
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/materiales/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registrar movimiento de inventario
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/materiales/movimiento
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"materialId": "cmkk6tf6f000x5x5s413dejgf",
|
||||||
|
"tipo": "SALIDA",
|
||||||
|
"cantidad": 50,
|
||||||
|
"motivo": "Envío a obra Edificio Aurora",
|
||||||
|
"obraId": "cmkk6tf4900085x5semza5bzu"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Campos:**
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|-------|------|-----------|-------------|
|
||||||
|
| materialId | string | Sí | ID del material |
|
||||||
|
| tipo | enum | Sí | ENTRADA, SALIDA, AJUSTE |
|
||||||
|
| cantidad | number | Sí | Cantidad del movimiento |
|
||||||
|
| motivo | string | No | Razón del movimiento |
|
||||||
|
| obraId | string | No | Obra destino (para salidas) |
|
||||||
|
|
||||||
|
**Respuesta exitosa (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "mov123",
|
||||||
|
"tipo": "SALIDA",
|
||||||
|
"cantidad": 50,
|
||||||
|
"motivo": "Envío a obra Edificio Aurora",
|
||||||
|
"materialId": "cmkk6tf6f000x5x5s413dejgf",
|
||||||
|
"obraId": "cmkk6tf4900085x5semza5bzu",
|
||||||
|
"createdAt": "2024-01-20T15:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** El stock del material se actualiza automáticamente según el tipo de movimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Códigos de Error
|
||||||
|
|
||||||
|
| Código | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | Bad Request - Datos inválidos |
|
||||||
|
| 401 | Unauthorized - No autenticado |
|
||||||
|
| 403 | Forbidden - Sin permisos |
|
||||||
|
| 404 | Not Found - Recurso no encontrado |
|
||||||
|
| 500 | Internal Server Error |
|
||||||
|
|
||||||
|
**Formato de error:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Descripción del error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplos con cURL
|
||||||
|
|
||||||
|
### Login y obtener materiales
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Obtener CSRF token
|
||||||
|
CSRF=$(curl -s -c cookies.txt http://localhost:3000/api/auth/csrf | grep -oP '"csrfToken":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
# 2. Login
|
||||||
|
curl -s -b cookies.txt -c cookies.txt \
|
||||||
|
-X POST "http://localhost:3000/api/auth/callback/credentials" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "csrfToken=$CSRF&email=admin@demo.com&password=admin123"
|
||||||
|
|
||||||
|
# 3. Obtener materiales
|
||||||
|
curl -s -b cookies.txt http://localhost:3000/api/materiales
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear un gasto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/gastos \
|
||||||
|
-b cookies.txt \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"concepto": "Compra de cemento",
|
||||||
|
"monto": 15000,
|
||||||
|
"categoria": "MATERIALES",
|
||||||
|
"fecha": "2024-01-20",
|
||||||
|
"obraId": "cmkk6tf4900085x5semza5bzu"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registrar entrada de material
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/materiales/movimiento \
|
||||||
|
-b cookies.txt \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"materialId": "cmkk6tf6f000x5x5s413dejgf",
|
||||||
|
"tipo": "ENTRADA",
|
||||||
|
"cantidad": 100,
|
||||||
|
"motivo": "Compra a proveedor"
|
||||||
|
}'
|
||||||
|
```
|
||||||
486
docs/DATABASE.md
Normal file
486
docs/DATABASE.md
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
# Documentación de Base de Datos - Mexus App
|
||||||
|
|
||||||
|
Este documento describe el esquema de base de datos utilizado en Mexus App.
|
||||||
|
|
||||||
|
## Diagrama de Relaciones
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Empresa │───────│ User │───────│ Gasto │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Obra │───────│ FaseObra │───────│ TareaObra │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
├──────────────┬──────────────┬──────────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Presupuesto │ │ Empleado │ │Subcontratist│ │ Material │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Partida │ │ Movimiento │
|
||||||
|
│ Presupuesto │ │ Inventario │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelos de Datos
|
||||||
|
|
||||||
|
### Empresa
|
||||||
|
|
||||||
|
Representa una empresa en el sistema (multi-tenant).
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| nombre | String | Nombre de la empresa |
|
||||||
|
| rfc | String? | RFC (único) |
|
||||||
|
| direccion | String? | Dirección |
|
||||||
|
| telefono | String? | Teléfono |
|
||||||
|
| email | String? | Email de contacto |
|
||||||
|
| logo | String? | URL del logo |
|
||||||
|
| activo | Boolean | Estado activo (default: true) |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `usuarios` → User[] (1:N)
|
||||||
|
- `obras` → Obra[] (1:N)
|
||||||
|
- `materiales` → Material[] (1:N)
|
||||||
|
- `empleados` → Empleado[] (1:N)
|
||||||
|
- `subcontratistas` → Subcontratista[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Usuarios del sistema con roles y permisos.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| email | String | Email (único) |
|
||||||
|
| password | String | Contraseña hasheada |
|
||||||
|
| nombre | String | Nombre |
|
||||||
|
| apellido | String | Apellido |
|
||||||
|
| telefono | String? | Teléfono |
|
||||||
|
| role | Role | Rol del usuario |
|
||||||
|
| activo | Boolean | Estado activo (default: true) |
|
||||||
|
| empresaId | String | ID de la empresa |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum Role:**
|
||||||
|
- `ADMIN` - Administrador con acceso total
|
||||||
|
- `GERENTE` - Gerente de proyectos
|
||||||
|
- `SUPERVISOR` - Supervisor de obra
|
||||||
|
- `CONTADOR` - Contador/finanzas
|
||||||
|
- `EMPLEADO` - Empleado general
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `empresa` → Empresa (N:1)
|
||||||
|
- `gastos` → Gasto[] (1:N)
|
||||||
|
- `obrasCreadas` → Obra[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Obra
|
||||||
|
|
||||||
|
Proyectos de construcción.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| codigo | String | Código único de obra |
|
||||||
|
| nombre | String | Nombre del proyecto |
|
||||||
|
| descripcion | String? | Descripción |
|
||||||
|
| cliente | String | Nombre del cliente |
|
||||||
|
| ubicacion | String? | Dirección/ubicación |
|
||||||
|
| estado | EstadoObra | Estado actual |
|
||||||
|
| presupuestoTotal | Float | Presupuesto total MXN |
|
||||||
|
| fechaInicio | DateTime | Fecha de inicio |
|
||||||
|
| fechaFinEstimada | DateTime? | Fecha fin estimada |
|
||||||
|
| fechaFinReal | DateTime? | Fecha fin real |
|
||||||
|
| avanceGeneral | Float | Porcentaje de avance (0-100) |
|
||||||
|
| empresaId | String | ID de la empresa |
|
||||||
|
| creadoPorId | String | ID del usuario creador |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum EstadoObra:**
|
||||||
|
- `PLANEACION` - En planeación
|
||||||
|
- `EN_PROGRESO` - En ejecución
|
||||||
|
- `PAUSADA` - Pausada temporalmente
|
||||||
|
- `COMPLETADA` - Finalizada
|
||||||
|
- `CANCELADA` - Cancelada
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `empresa` → Empresa (N:1)
|
||||||
|
- `creadoPor` → User (N:1)
|
||||||
|
- `fases` → FaseObra[] (1:N)
|
||||||
|
- `gastos` → Gasto[] (1:N)
|
||||||
|
- `presupuestos` → Presupuesto[] (1:N)
|
||||||
|
- `empleadosAsignados` → EmpleadoObra[] (1:N)
|
||||||
|
- `subcontratistasAsignados` → SubcontratistaObra[] (1:N)
|
||||||
|
- `movimientosInventario` → MovimientoInventario[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FaseObra
|
||||||
|
|
||||||
|
Fases o etapas de una obra.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| nombre | String | Nombre de la fase |
|
||||||
|
| descripcion | String? | Descripción |
|
||||||
|
| orden | Int | Orden de ejecución |
|
||||||
|
| fechaInicio | DateTime? | Fecha de inicio |
|
||||||
|
| fechaFin | DateTime? | Fecha de fin |
|
||||||
|
| avance | Float | Porcentaje de avance (default: 0) |
|
||||||
|
| estado | EstadoFase | Estado de la fase |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum EstadoFase:**
|
||||||
|
- `PENDIENTE` - No iniciada
|
||||||
|
- `EN_PROGRESO` - En ejecución
|
||||||
|
- `COMPLETADA` - Finalizada
|
||||||
|
- `CANCELADA` - Cancelada
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `obra` → Obra (N:1)
|
||||||
|
- `tareas` → TareaObra[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TareaObra
|
||||||
|
|
||||||
|
Tareas específicas dentro de una fase.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| nombre | String | Nombre de la tarea |
|
||||||
|
| descripcion | String? | Descripción |
|
||||||
|
| fechaInicio | DateTime? | Fecha de inicio |
|
||||||
|
| fechaFin | DateTime? | Fecha de fin |
|
||||||
|
| estado | EstadoTarea | Estado de la tarea |
|
||||||
|
| faseId | String | ID de la fase |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum EstadoTarea:**
|
||||||
|
- `PENDIENTE` - No iniciada
|
||||||
|
- `EN_PROGRESO` - En ejecución
|
||||||
|
- `COMPLETADA` - Finalizada
|
||||||
|
- `BLOQUEADA` - Bloqueada por dependencia
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `fase` → FaseObra (N:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Presupuesto
|
||||||
|
|
||||||
|
Presupuestos asociados a obras.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| nombre | String | Nombre del presupuesto |
|
||||||
|
| version | Int | Número de versión (default: 1) |
|
||||||
|
| montoTotal | Float | Monto total calculado |
|
||||||
|
| aprobado | Boolean | Estado de aprobación |
|
||||||
|
| fechaAprobacion | DateTime? | Fecha de aprobación |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `obra` → Obra (N:1)
|
||||||
|
- `partidas` → PartidaPresupuesto[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PartidaPresupuesto
|
||||||
|
|
||||||
|
Líneas de detalle de un presupuesto.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| concepto | String | Concepto/descripción |
|
||||||
|
| unidad | String | Unidad de medida |
|
||||||
|
| cantidad | Float | Cantidad |
|
||||||
|
| precioUnitario | Float | Precio por unidad |
|
||||||
|
| subtotal | Float | Subtotal calculado |
|
||||||
|
| presupuestoId | String | ID del presupuesto |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `presupuesto` → Presupuesto (N:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gasto
|
||||||
|
|
||||||
|
Registro de gastos de obra.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| concepto | String | Concepto del gasto |
|
||||||
|
| descripcion | String? | Descripción detallada |
|
||||||
|
| monto | Float | Monto en MXN |
|
||||||
|
| categoria | CategoriaGasto | Categoría |
|
||||||
|
| fecha | DateTime | Fecha del gasto |
|
||||||
|
| comprobante | String? | Número de comprobante |
|
||||||
|
| estado | EstadoGasto | Estado de aprobación |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| usuarioId | String | ID del usuario que registra |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum CategoriaGasto:**
|
||||||
|
- `MATERIALES` - Compra de materiales
|
||||||
|
- `MANO_OBRA` - Pago de mano de obra
|
||||||
|
- `SUBCONTRATO` - Pagos a subcontratistas
|
||||||
|
- `EQUIPO` - Renta/compra de equipo
|
||||||
|
- `TRANSPORTE` - Fletes y transporte
|
||||||
|
- `ADMINISTRATIVO` - Gastos administrativos
|
||||||
|
- `OTROS` - Otros gastos
|
||||||
|
|
||||||
|
**Enum EstadoGasto:**
|
||||||
|
- `PENDIENTE` - Pendiente de aprobación
|
||||||
|
- `APROBADO` - Aprobado
|
||||||
|
- `RECHAZADO` - Rechazado
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `obra` → Obra (N:1)
|
||||||
|
- `usuario` → User (N:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Factura
|
||||||
|
|
||||||
|
Facturas emitidas y recibidas.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| numero | String | Número de factura |
|
||||||
|
| tipo | TipoFactura | Tipo (emitida/recibida) |
|
||||||
|
| cliente | String? | Nombre del cliente |
|
||||||
|
| proveedor | String? | Nombre del proveedor |
|
||||||
|
| subtotal | Float | Subtotal |
|
||||||
|
| iva | Float | IVA |
|
||||||
|
| total | Float | Total |
|
||||||
|
| fechaEmision | DateTime | Fecha de emisión |
|
||||||
|
| fechaVencimiento | DateTime? | Fecha de vencimiento |
|
||||||
|
| estado | EstadoFactura | Estado de pago |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum TipoFactura:**
|
||||||
|
- `EMITIDA` - Factura a cliente
|
||||||
|
- `RECIBIDA` - Factura de proveedor
|
||||||
|
|
||||||
|
**Enum EstadoFactura:**
|
||||||
|
- `PENDIENTE` - Pendiente de pago
|
||||||
|
- `PAGADA` - Pagada
|
||||||
|
- `VENCIDA` - Vencida
|
||||||
|
- `CANCELADA` - Cancelada
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `obra` → Obra (N:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Material
|
||||||
|
|
||||||
|
Catálogo de materiales de construcción.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| codigo | String | Código único |
|
||||||
|
| nombre | String | Nombre del material |
|
||||||
|
| descripcion | String? | Descripción |
|
||||||
|
| unidad | UnidadMedida | Unidad de medida |
|
||||||
|
| precioUnitario | Float | Precio por unidad |
|
||||||
|
| stockMinimo | Float | Stock mínimo para alertas |
|
||||||
|
| stockActual | Float | Stock actual (default: 0) |
|
||||||
|
| ubicacion | String? | Ubicación en bodega |
|
||||||
|
| activo | Boolean | Estado activo (default: true) |
|
||||||
|
| empresaId | String | ID de la empresa |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Enum UnidadMedida:**
|
||||||
|
- `PIEZA` - Pieza
|
||||||
|
- `METRO` - Metro lineal
|
||||||
|
- `METRO_CUADRADO` - Metro cuadrado
|
||||||
|
- `METRO_CUBICO` - Metro cúbico
|
||||||
|
- `KILOGRAMO` - Kilogramo
|
||||||
|
- `LITRO` - Litro
|
||||||
|
- `BOLSA` - Bolsa
|
||||||
|
- `BULTO` - Bulto
|
||||||
|
- `ROLLO` - Rollo
|
||||||
|
- `CAJA` - Caja
|
||||||
|
- `UNIDAD` - Unidad
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `empresa` → Empresa (N:1)
|
||||||
|
- `movimientos` → MovimientoInventario[] (1:N)
|
||||||
|
|
||||||
|
**Índice único:** `@@unique([codigo, empresaId])`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MovimientoInventario
|
||||||
|
|
||||||
|
Registro de movimientos de inventario.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| tipo | TipoMovimiento | Tipo de movimiento |
|
||||||
|
| cantidad | Float | Cantidad |
|
||||||
|
| motivo | String? | Motivo/descripción |
|
||||||
|
| materialId | String | ID del material |
|
||||||
|
| obraId | String? | ID de la obra (opcional) |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
|
||||||
|
**Enum TipoMovimiento:**
|
||||||
|
- `ENTRADA` - Entrada de material
|
||||||
|
- `SALIDA` - Salida de material
|
||||||
|
- `AJUSTE` - Ajuste de inventario
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `material` → Material (N:1)
|
||||||
|
- `obra` → Obra (N:1, opcional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Empleado
|
||||||
|
|
||||||
|
Personal de la empresa.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| codigo | String | Código de empleado |
|
||||||
|
| nombre | String | Nombre |
|
||||||
|
| apellido | String | Apellido |
|
||||||
|
| curp | String? | CURP |
|
||||||
|
| rfc | String? | RFC |
|
||||||
|
| telefono | String? | Teléfono |
|
||||||
|
| email | String? | Email |
|
||||||
|
| puesto | String | Puesto/cargo |
|
||||||
|
| salarioDiario | Float | Salario diario |
|
||||||
|
| fechaIngreso | DateTime | Fecha de ingreso |
|
||||||
|
| activo | Boolean | Estado activo (default: true) |
|
||||||
|
| empresaId | String | ID de la empresa |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `empresa` → Empresa (N:1)
|
||||||
|
- `obrasAsignadas` → EmpleadoObra[] (1:N)
|
||||||
|
- `jornadas` → JornadaLaboral[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Subcontratista
|
||||||
|
|
||||||
|
Empresas subcontratadas.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| nombre | String | Nombre/razón social |
|
||||||
|
| rfc | String? | RFC |
|
||||||
|
| contacto | String? | Persona de contacto |
|
||||||
|
| telefono | String? | Teléfono |
|
||||||
|
| email | String? | Email |
|
||||||
|
| especialidad | String | Especialidad/giro |
|
||||||
|
| activo | Boolean | Estado activo (default: true) |
|
||||||
|
| empresaId | String | ID de la empresa |
|
||||||
|
| createdAt | DateTime | Fecha de creación |
|
||||||
|
| updatedAt | DateTime | Fecha de actualización |
|
||||||
|
|
||||||
|
**Relaciones:**
|
||||||
|
- `empresa` → Empresa (N:1)
|
||||||
|
- `obrasAsignadas` → SubcontratistaObra[] (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tablas de Relación (N:M)
|
||||||
|
|
||||||
|
### EmpleadoObra
|
||||||
|
|
||||||
|
Asignación de empleados a obras.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| empleadoId | String | ID del empleado |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| fechaAsignacion | DateTime | Fecha de asignación |
|
||||||
|
| fechaFin | DateTime? | Fecha de fin |
|
||||||
|
| activo | Boolean | Asignación activa |
|
||||||
|
|
||||||
|
### SubcontratistaObra
|
||||||
|
|
||||||
|
Asignación de subcontratistas a obras.
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | String | ID único (CUID) |
|
||||||
|
| subcontratistaId | String | ID del subcontratista |
|
||||||
|
| obraId | String | ID de la obra |
|
||||||
|
| descripcionTrabajo | String | Descripción del trabajo |
|
||||||
|
| montoContrato | Float | Monto del contrato |
|
||||||
|
| fechaInicio | DateTime | Fecha de inicio |
|
||||||
|
| fechaFin | DateTime? | Fecha de fin |
|
||||||
|
| estado | EstadoContrato | Estado del contrato |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos de Prisma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generar cliente Prisma
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Crear migración
|
||||||
|
npx prisma migrate dev --name descripcion_cambio
|
||||||
|
|
||||||
|
# Aplicar migraciones en producción
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Ver base de datos en navegador
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# Ejecutar seed
|
||||||
|
npx prisma db seed
|
||||||
|
|
||||||
|
# Resetear base de datos (desarrollo)
|
||||||
|
npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consideraciones de Seguridad
|
||||||
|
|
||||||
|
1. **Multi-tenant**: Todas las queries filtran por `empresaId` del usuario autenticado
|
||||||
|
2. **Índices**: Se han creado índices en campos frecuentemente consultados
|
||||||
|
3. **Cascade**: Las eliminaciones en cascada están configuradas para mantener integridad
|
||||||
|
4. **Soft delete**: Los registros importantes usan campo `activo` en lugar de eliminación física
|
||||||
19
next.config.js
Normal file
19
next.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "2mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
63
nginx.conf
Normal file
63
nginx.conf
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream nextjs {
|
||||||
|
server app:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://nextjs;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limit API endpoints
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://nextjs;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files caching
|
||||||
|
location /_next/static {
|
||||||
|
proxy_pass http://nextjs;
|
||||||
|
proxy_cache_valid 60m;
|
||||||
|
add_header Cache-Control "public, max-age=3600, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8548
package-lock.json
generated
Normal file
8548
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
package.json
Normal file
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "construccion-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"docker:dev": "docker-compose up -d",
|
||||||
|
"docker:down": "docker-compose down",
|
||||||
|
"docker:build": "docker-compose build",
|
||||||
|
"docker:prod": "docker-compose -f docker-compose.prod.yml up -d"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "^14.2.28",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"recharts": "^2.13.0",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.17.6",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^14.2.28",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
466
prisma/schema.prisma
Normal file
466
prisma/schema.prisma
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
// Prisma schema for Construction Management System
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== ENUMS ==============
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
GERENTE
|
||||||
|
SUPERVISOR
|
||||||
|
CONTADOR
|
||||||
|
EMPLEADO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EstadoObra {
|
||||||
|
PLANIFICACION
|
||||||
|
EN_PROGRESO
|
||||||
|
PAUSADA
|
||||||
|
COMPLETADA
|
||||||
|
CANCELADA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EstadoTarea {
|
||||||
|
PENDIENTE
|
||||||
|
EN_PROGRESO
|
||||||
|
COMPLETADA
|
||||||
|
BLOQUEADA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EstadoGasto {
|
||||||
|
PENDIENTE
|
||||||
|
APROBADO
|
||||||
|
RECHAZADO
|
||||||
|
PAGADO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CategoriaGasto {
|
||||||
|
MATERIALES
|
||||||
|
MANO_DE_OBRA
|
||||||
|
EQUIPOS
|
||||||
|
SUBCONTRATISTAS
|
||||||
|
PERMISOS
|
||||||
|
TRANSPORTE
|
||||||
|
SERVICIOS
|
||||||
|
OTROS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TipoFactura {
|
||||||
|
EMITIDA
|
||||||
|
RECIBIDA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EstadoFactura {
|
||||||
|
PENDIENTE
|
||||||
|
PAGADA
|
||||||
|
VENCIDA
|
||||||
|
CANCELADA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TipoMovimiento {
|
||||||
|
ENTRADA
|
||||||
|
SALIDA
|
||||||
|
AJUSTE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UnidadMedida {
|
||||||
|
UNIDAD
|
||||||
|
METRO
|
||||||
|
METRO_CUADRADO
|
||||||
|
METRO_CUBICO
|
||||||
|
KILOGRAMO
|
||||||
|
TONELADA
|
||||||
|
LITRO
|
||||||
|
BOLSA
|
||||||
|
PIEZA
|
||||||
|
ROLLO
|
||||||
|
CAJA
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== MODELS ==============
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
nombre String
|
||||||
|
apellido String
|
||||||
|
role Role @default(EMPLEADO)
|
||||||
|
telefono String?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
gastosCreados Gasto[] @relation("GastoCreador")
|
||||||
|
gastosAprobados Gasto[] @relation("GastoAprobador")
|
||||||
|
tareasAsignadas TareaObra[]
|
||||||
|
obrasSupervision Obra[] @relation("ObraSupervisor")
|
||||||
|
registrosAvance RegistroAvance[]
|
||||||
|
|
||||||
|
@@index([empresaId])
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Empresa {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
rfc String? @unique
|
||||||
|
direccion String?
|
||||||
|
telefono String?
|
||||||
|
email String?
|
||||||
|
logo String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
usuarios User[]
|
||||||
|
obras Obra[]
|
||||||
|
materiales Material[]
|
||||||
|
empleados Empleado[]
|
||||||
|
subcontratistas Subcontratista[]
|
||||||
|
clientes Cliente[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cliente {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
rfc String?
|
||||||
|
direccion String?
|
||||||
|
telefono String?
|
||||||
|
email String?
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
obras Obra[]
|
||||||
|
|
||||||
|
@@index([empresaId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Obra {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
direccion String
|
||||||
|
estado EstadoObra @default(PLANIFICACION)
|
||||||
|
fechaInicio DateTime?
|
||||||
|
fechaFinPrevista DateTime?
|
||||||
|
fechaFinReal DateTime?
|
||||||
|
porcentajeAvance Float @default(0)
|
||||||
|
presupuestoTotal Float @default(0)
|
||||||
|
gastoTotal Float @default(0)
|
||||||
|
imagenPortada String?
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
clienteId String?
|
||||||
|
cliente Cliente? @relation(fields: [clienteId], references: [id])
|
||||||
|
supervisorId String?
|
||||||
|
supervisor User? @relation("ObraSupervisor", fields: [supervisorId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
fases FaseObra[]
|
||||||
|
presupuestos Presupuesto[]
|
||||||
|
gastos Gasto[]
|
||||||
|
facturas Factura[]
|
||||||
|
movimientosInv MovimientoInventario[]
|
||||||
|
asignaciones AsignacionEmpleado[]
|
||||||
|
contratos ContratoSubcontratista[]
|
||||||
|
registrosAvance RegistroAvance[]
|
||||||
|
|
||||||
|
@@index([empresaId])
|
||||||
|
@@index([estado])
|
||||||
|
@@index([clienteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FaseObra {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
orden Int
|
||||||
|
fechaInicio DateTime?
|
||||||
|
fechaFin DateTime?
|
||||||
|
porcentajeAvance Float @default(0)
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
tareas TareaObra[]
|
||||||
|
|
||||||
|
@@index([obraId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TareaObra {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
estado EstadoTarea @default(PENDIENTE)
|
||||||
|
prioridad Int @default(1)
|
||||||
|
fechaInicio DateTime?
|
||||||
|
fechaFin DateTime?
|
||||||
|
porcentajeAvance Float @default(0)
|
||||||
|
faseId String
|
||||||
|
fase FaseObra @relation(fields: [faseId], references: [id], onDelete: Cascade)
|
||||||
|
asignadoId String?
|
||||||
|
asignado User? @relation(fields: [asignadoId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([faseId])
|
||||||
|
@@index([estado])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RegistroAvance {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
descripcion String
|
||||||
|
porcentaje Float
|
||||||
|
fotos String[]
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
|
||||||
|
registradoPorId String
|
||||||
|
registradoPor User @relation(fields: [registradoPorId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([obraId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Presupuesto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
version Int @default(1)
|
||||||
|
total Float @default(0)
|
||||||
|
aprobado Boolean @default(false)
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
partidas PartidaPresupuesto[]
|
||||||
|
|
||||||
|
@@index([obraId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PartidaPresupuesto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
codigo String
|
||||||
|
descripcion String
|
||||||
|
unidad UnidadMedida
|
||||||
|
cantidad Float
|
||||||
|
precioUnitario Float
|
||||||
|
total Float
|
||||||
|
categoria CategoriaGasto
|
||||||
|
presupuestoId String
|
||||||
|
presupuesto Presupuesto @relation(fields: [presupuestoId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
gastos Gasto[]
|
||||||
|
|
||||||
|
@@index([presupuestoId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Gasto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
concepto String
|
||||||
|
descripcion String?
|
||||||
|
monto Float
|
||||||
|
fecha DateTime
|
||||||
|
categoria CategoriaGasto
|
||||||
|
estado EstadoGasto @default(PENDIENTE)
|
||||||
|
comprobante String?
|
||||||
|
notas String?
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
|
||||||
|
partidaId String?
|
||||||
|
partida PartidaPresupuesto? @relation(fields: [partidaId], references: [id])
|
||||||
|
creadoPorId String
|
||||||
|
creadoPor User @relation("GastoCreador", fields: [creadoPorId], references: [id])
|
||||||
|
aprobadoPorId String?
|
||||||
|
aprobadoPor User? @relation("GastoAprobador", fields: [aprobadoPorId], references: [id])
|
||||||
|
fechaAprobacion DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([obraId])
|
||||||
|
@@index([estado])
|
||||||
|
@@index([categoria])
|
||||||
|
@@index([fecha])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Factura {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
numero String
|
||||||
|
tipo TipoFactura
|
||||||
|
concepto String
|
||||||
|
monto Float
|
||||||
|
iva Float @default(0)
|
||||||
|
total Float
|
||||||
|
fechaEmision DateTime
|
||||||
|
fechaVencimiento DateTime?
|
||||||
|
fechaPago DateTime?
|
||||||
|
estado EstadoFactura @default(PENDIENTE)
|
||||||
|
archivo String?
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade)
|
||||||
|
proveedorNombre String?
|
||||||
|
proveedorRfc String?
|
||||||
|
clienteNombre String?
|
||||||
|
clienteRfc String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([obraId])
|
||||||
|
@@index([tipo])
|
||||||
|
@@index([estado])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Material {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
codigo String
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
unidad UnidadMedida
|
||||||
|
precioUnitario Float
|
||||||
|
stockMinimo Float @default(0)
|
||||||
|
stockActual Float @default(0)
|
||||||
|
ubicacion String?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
movimientos MovimientoInventario[]
|
||||||
|
|
||||||
|
@@unique([codigo, empresaId])
|
||||||
|
@@index([empresaId])
|
||||||
|
@@index([nombre])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MovimientoInventario {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tipo TipoMovimiento
|
||||||
|
cantidad Float
|
||||||
|
motivo String?
|
||||||
|
materialId String
|
||||||
|
material Material @relation(fields: [materialId], references: [id])
|
||||||
|
obraId String?
|
||||||
|
obra Obra? @relation(fields: [obraId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([materialId])
|
||||||
|
@@index([obraId])
|
||||||
|
@@index([tipo])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Empleado {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
apellido String
|
||||||
|
documento String?
|
||||||
|
telefono String?
|
||||||
|
email String?
|
||||||
|
puesto String
|
||||||
|
salarioBase Float?
|
||||||
|
fechaIngreso DateTime
|
||||||
|
fechaBaja DateTime?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
asignaciones AsignacionEmpleado[]
|
||||||
|
jornadas JornadaTrabajo[]
|
||||||
|
|
||||||
|
@@index([empresaId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AsignacionEmpleado {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fechaInicio DateTime
|
||||||
|
fechaFin DateTime?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
empleadoId String
|
||||||
|
empleado Empleado @relation(fields: [empleadoId], references: [id])
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([empleadoId])
|
||||||
|
@@index([obraId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model JornadaTrabajo {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fecha DateTime
|
||||||
|
horasRegular Float @default(0)
|
||||||
|
horasExtra Float @default(0)
|
||||||
|
notas String?
|
||||||
|
empleadoId String
|
||||||
|
empleado Empleado @relation(fields: [empleadoId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([empleadoId])
|
||||||
|
@@index([fecha])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subcontratista {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
rfc String?
|
||||||
|
especialidad String
|
||||||
|
telefono String?
|
||||||
|
email String?
|
||||||
|
direccion String?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
empresaId String
|
||||||
|
empresa Empresa @relation(fields: [empresaId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
contratos ContratoSubcontratista[]
|
||||||
|
|
||||||
|
@@index([empresaId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ContratoSubcontratista {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
descripcion String
|
||||||
|
montoContratado Float
|
||||||
|
montoPagado Float @default(0)
|
||||||
|
fechaInicio DateTime
|
||||||
|
fechaFin DateTime?
|
||||||
|
estado String @default("ACTIVO")
|
||||||
|
subcontratistaId String
|
||||||
|
subcontratista Subcontratista @relation(fields: [subcontratistaId], references: [id])
|
||||||
|
obraId String
|
||||||
|
obra Obra @relation(fields: [obraId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([subcontratistaId])
|
||||||
|
@@index([obraId])
|
||||||
|
}
|
||||||
315
prisma/seed.ts
Normal file
315
prisma/seed.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { PrismaClient, Role, EstadoObra, CategoriaGasto } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
// Create empresa
|
||||||
|
const empresa = await prisma.empresa.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Constructora Demo S.A. de C.V.",
|
||||||
|
rfc: "CDM123456ABC",
|
||||||
|
direccion: "Av. Principal #123, Ciudad de Mexico",
|
||||||
|
telefono: "55 1234 5678",
|
||||||
|
email: "contacto@constructorademo.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created empresa:", empresa.nombre);
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const hashedPassword = await bcrypt.hash("admin123", 12);
|
||||||
|
const adminUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: "admin@demo.com",
|
||||||
|
password: hashedPassword,
|
||||||
|
nombre: "Admin",
|
||||||
|
apellido: "Demo",
|
||||||
|
role: Role.ADMIN,
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created admin user:", adminUser.email);
|
||||||
|
|
||||||
|
// Create supervisor user
|
||||||
|
const supervisorUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: "supervisor@demo.com",
|
||||||
|
password: hashedPassword,
|
||||||
|
nombre: "Juan",
|
||||||
|
apellido: "Supervisor",
|
||||||
|
role: Role.SUPERVISOR,
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create cliente
|
||||||
|
const cliente = await prisma.cliente.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Cliente Residencial SA",
|
||||||
|
rfc: "CRS987654XYZ",
|
||||||
|
direccion: "Calle Ejemplo #456",
|
||||||
|
telefono: "55 9876 5432",
|
||||||
|
email: "cliente@ejemplo.com",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created cliente:", cliente.nombre);
|
||||||
|
|
||||||
|
// Create obra
|
||||||
|
const obra = await prisma.obra.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Torre Residencial Norte",
|
||||||
|
descripcion: "Proyecto de torre residencial de 20 pisos con amenidades",
|
||||||
|
direccion: "Av. Reforma #500, CDMX",
|
||||||
|
estado: EstadoObra.EN_PROGRESO,
|
||||||
|
fechaInicio: new Date("2024-01-15"),
|
||||||
|
fechaFinPrevista: new Date("2025-06-30"),
|
||||||
|
porcentajeAvance: 35,
|
||||||
|
presupuestoTotal: 15000000,
|
||||||
|
gastoTotal: 5250000,
|
||||||
|
empresaId: empresa.id,
|
||||||
|
clienteId: cliente.id,
|
||||||
|
supervisorId: supervisorUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created obra:", obra.nombre);
|
||||||
|
|
||||||
|
// Create fases
|
||||||
|
const fases = await Promise.all([
|
||||||
|
prisma.faseObra.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Cimentacion",
|
||||||
|
descripcion: "Excavacion y cimentacion profunda",
|
||||||
|
orden: 1,
|
||||||
|
porcentajeAvance: 100,
|
||||||
|
obraId: obra.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.faseObra.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Estructura",
|
||||||
|
descripcion: "Construccion de estructura de concreto",
|
||||||
|
orden: 2,
|
||||||
|
porcentajeAvance: 60,
|
||||||
|
obraId: obra.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.faseObra.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Instalaciones",
|
||||||
|
descripcion: "Instalaciones electricas, hidraulicas y sanitarias",
|
||||||
|
orden: 3,
|
||||||
|
porcentajeAvance: 10,
|
||||||
|
obraId: obra.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Created", fases.length, "fases");
|
||||||
|
|
||||||
|
// Create presupuesto
|
||||||
|
const presupuesto = await prisma.presupuesto.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Presupuesto Principal 2024",
|
||||||
|
descripcion: "Presupuesto inicial aprobado",
|
||||||
|
total: 15000000,
|
||||||
|
aprobado: true,
|
||||||
|
obraId: obra.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create partidas
|
||||||
|
await Promise.all([
|
||||||
|
prisma.partidaPresupuesto.create({
|
||||||
|
data: {
|
||||||
|
codigo: "CIM-001",
|
||||||
|
descripcion: "Excavacion y cimentacion",
|
||||||
|
unidad: "METRO_CUBICO",
|
||||||
|
cantidad: 500,
|
||||||
|
precioUnitario: 2500,
|
||||||
|
total: 1250000,
|
||||||
|
categoria: CategoriaGasto.MANO_DE_OBRA,
|
||||||
|
presupuestoId: presupuesto.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.partidaPresupuesto.create({
|
||||||
|
data: {
|
||||||
|
codigo: "EST-001",
|
||||||
|
descripcion: "Concreto estructural",
|
||||||
|
unidad: "METRO_CUBICO",
|
||||||
|
cantidad: 2000,
|
||||||
|
precioUnitario: 3500,
|
||||||
|
total: 7000000,
|
||||||
|
categoria: CategoriaGasto.MATERIALES,
|
||||||
|
presupuestoId: presupuesto.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create gastos
|
||||||
|
const gastos = await Promise.all([
|
||||||
|
prisma.gasto.create({
|
||||||
|
data: {
|
||||||
|
concepto: "Compra de cemento",
|
||||||
|
descripcion: "500 toneladas de cemento Portland",
|
||||||
|
monto: 1500000,
|
||||||
|
fecha: new Date("2024-02-01"),
|
||||||
|
categoria: CategoriaGasto.MATERIALES,
|
||||||
|
estado: "APROBADO",
|
||||||
|
obraId: obra.id,
|
||||||
|
creadoPorId: adminUser.id,
|
||||||
|
aprobadoPorId: adminUser.id,
|
||||||
|
fechaAprobacion: new Date("2024-02-02"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.gasto.create({
|
||||||
|
data: {
|
||||||
|
concepto: "Renta de maquinaria",
|
||||||
|
descripcion: "Excavadora y retroexcavadora",
|
||||||
|
monto: 750000,
|
||||||
|
fecha: new Date("2024-02-15"),
|
||||||
|
categoria: CategoriaGasto.EQUIPOS,
|
||||||
|
estado: "APROBADO",
|
||||||
|
obraId: obra.id,
|
||||||
|
creadoPorId: supervisorUser.id,
|
||||||
|
aprobadoPorId: adminUser.id,
|
||||||
|
fechaAprobacion: new Date("2024-02-16"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.gasto.create({
|
||||||
|
data: {
|
||||||
|
concepto: "Nomina trabajadores",
|
||||||
|
descripcion: "Pago quincenal",
|
||||||
|
monto: 500000,
|
||||||
|
fecha: new Date("2024-03-01"),
|
||||||
|
categoria: CategoriaGasto.MANO_DE_OBRA,
|
||||||
|
estado: "PAGADO",
|
||||||
|
obraId: obra.id,
|
||||||
|
creadoPorId: adminUser.id,
|
||||||
|
aprobadoPorId: adminUser.id,
|
||||||
|
fechaAprobacion: new Date("2024-03-01"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.gasto.create({
|
||||||
|
data: {
|
||||||
|
concepto: "Compra de acero",
|
||||||
|
descripcion: "Varilla corrugada",
|
||||||
|
monto: 2000000,
|
||||||
|
fecha: new Date("2024-03-15"),
|
||||||
|
categoria: CategoriaGasto.MATERIALES,
|
||||||
|
estado: "PENDIENTE",
|
||||||
|
obraId: obra.id,
|
||||||
|
creadoPorId: supervisorUser.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Created", gastos.length, "gastos");
|
||||||
|
|
||||||
|
// Create materiales
|
||||||
|
const materiales = await Promise.all([
|
||||||
|
prisma.material.create({
|
||||||
|
data: {
|
||||||
|
codigo: "CEM-001",
|
||||||
|
nombre: "Cemento Portland",
|
||||||
|
descripcion: "Cemento gris tipo I",
|
||||||
|
unidad: "BOLSA",
|
||||||
|
precioUnitario: 180,
|
||||||
|
stockMinimo: 100,
|
||||||
|
stockActual: 250,
|
||||||
|
ubicacion: "Bodega A",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.material.create({
|
||||||
|
data: {
|
||||||
|
codigo: "VAR-001",
|
||||||
|
nombre: "Varilla corrugada 3/8",
|
||||||
|
descripcion: "Varilla de acero corrugado",
|
||||||
|
unidad: "PIEZA",
|
||||||
|
precioUnitario: 120,
|
||||||
|
stockMinimo: 500,
|
||||||
|
stockActual: 1200,
|
||||||
|
ubicacion: "Bodega B",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.material.create({
|
||||||
|
data: {
|
||||||
|
codigo: "GRA-001",
|
||||||
|
nombre: "Grava 3/4",
|
||||||
|
descripcion: "Grava triturada",
|
||||||
|
unidad: "METRO_CUBICO",
|
||||||
|
precioUnitario: 450,
|
||||||
|
stockMinimo: 50,
|
||||||
|
stockActual: 30,
|
||||||
|
ubicacion: "Patio",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Created", materiales.length, "materiales");
|
||||||
|
|
||||||
|
// Create empleados
|
||||||
|
const empleados = await Promise.all([
|
||||||
|
prisma.empleado.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Pedro",
|
||||||
|
apellido: "Martinez",
|
||||||
|
documento: "PEMA850101ABC",
|
||||||
|
telefono: "55 1111 2222",
|
||||||
|
puesto: "Maestro de Obra",
|
||||||
|
salarioBase: 25000,
|
||||||
|
fechaIngreso: new Date("2023-01-15"),
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.empleado.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Maria",
|
||||||
|
apellido: "Garcia",
|
||||||
|
documento: "MAGA900520XYZ",
|
||||||
|
telefono: "55 3333 4444",
|
||||||
|
puesto: "Ingeniero Residente",
|
||||||
|
salarioBase: 35000,
|
||||||
|
fechaIngreso: new Date("2023-03-01"),
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Created", empleados.length, "empleados");
|
||||||
|
|
||||||
|
// Create subcontratistas
|
||||||
|
await prisma.subcontratista.create({
|
||||||
|
data: {
|
||||||
|
nombre: "Instalaciones Electromecanicas SA",
|
||||||
|
rfc: "IEL123456ABC",
|
||||||
|
especialidad: "Instalaciones Electricas",
|
||||||
|
telefono: "55 5555 6666",
|
||||||
|
email: "contacto@ielsa.com",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seed completed successfully!");
|
||||||
|
console.log("\nCredenciales de acceso:");
|
||||||
|
console.log("Email: admin@demo.com");
|
||||||
|
console.log("Password: admin123");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
11
src/app/(auth)/layout.tsx
Normal file
11
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
||||||
|
<div className="w-full max-w-md p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/(auth)/login/page.tsx
Normal file
115
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { loginSchema, type LoginInput } from "@/lib/validations";
|
||||||
|
import { Building2, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginInput>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginInput) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError("Credenciales invalidas. Verifica tu email y contrasena.");
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Ocurrio un error. Intenta de nuevo.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<div className="p-3 bg-primary rounded-full">
|
||||||
|
<Building2 className="h-8 w-8 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl text-center">Iniciar Sesion</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Sistema de Gestion de Obras de Construccion
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Correo electronico</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Contrasena</Label>
|
||||||
|
<Input id="password" type="password" {...register("password")} />
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Iniciar Sesion
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
No tienes cuenta?{" "}
|
||||||
|
<Link href="/registro" className="text-primary hover:underline">
|
||||||
|
Registrate aqui
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/app/(auth)/registro/page.tsx
Normal file
163
src/app/(auth)/registro/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { registerSchema, type RegisterInput } from "@/lib/validations";
|
||||||
|
import { Building2, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function RegistroPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterInput>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterInput) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(result.error || "Error al registrar usuario");
|
||||||
|
} else {
|
||||||
|
router.push("/login?registered=true");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Ocurrio un error. Intenta de nuevo.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<div className="p-3 bg-primary rounded-full">
|
||||||
|
<Building2 className="h-8 w-8 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl text-center">Crear Cuenta</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Registra tu empresa para comenzar
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nombre">Nombre</Label>
|
||||||
|
<Input id="nombre" placeholder="Juan" {...register("nombre")} />
|
||||||
|
{errors.nombre && (
|
||||||
|
<p className="text-sm text-red-600">{errors.nombre.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="apellido">Apellido</Label>
|
||||||
|
<Input
|
||||||
|
id="apellido"
|
||||||
|
placeholder="Perez"
|
||||||
|
{...register("apellido")}
|
||||||
|
/>
|
||||||
|
{errors.apellido && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.apellido.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="empresaNombre">Nombre de la Empresa</Label>
|
||||||
|
<Input
|
||||||
|
id="empresaNombre"
|
||||||
|
placeholder="Constructora ABC"
|
||||||
|
{...register("empresaNombre")}
|
||||||
|
/>
|
||||||
|
{errors.empresaNombre && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.empresaNombre.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Correo electronico</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Contrasena</Label>
|
||||||
|
<Input id="password" type="password" {...register("password")} />
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirmar Contrasena</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Crear Cuenta
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
Ya tienes cuenta?{" "}
|
||||||
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
|
Inicia sesion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/app/(dashboard)/dashboard/dashboard-client.tsx
Normal file
297
src/app/(dashboard)/dashboard/dashboard-client.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
formatDateShort,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ESTADO_OBRA_LABELS,
|
||||||
|
ESTADO_OBRA_COLORS,
|
||||||
|
CATEGORIA_GASTO_LABELS,
|
||||||
|
type EstadoObra,
|
||||||
|
type CategoriaGasto,
|
||||||
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
stats: {
|
||||||
|
obrasActivas: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
avancePromedio: number;
|
||||||
|
obrasCompletadas: number;
|
||||||
|
gastoPendiente: number;
|
||||||
|
};
|
||||||
|
obrasRecientes: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
}[];
|
||||||
|
gastosPorMes: {
|
||||||
|
mes: string;
|
||||||
|
gastos: number;
|
||||||
|
presupuesto: number;
|
||||||
|
}[];
|
||||||
|
gastosPorCategoria: {
|
||||||
|
categoria: string;
|
||||||
|
total: number;
|
||||||
|
porcentaje: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884d8",
|
||||||
|
"#82ca9d",
|
||||||
|
"#ffc658",
|
||||||
|
"#ff7300",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardClient({ data }: { data: DashboardData }) {
|
||||||
|
const { stats, obrasRecientes, gastosPorMes, gastosPorCategoria } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Resumen general de tus obras y finanzas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Obras Activas</CardTitle>
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.obrasActivas}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{stats.obrasCompletadas} completadas
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Presupuesto Total
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(stats.presupuestoTotal)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Gastado: {formatCurrency(stats.gastoTotal)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Avance Promedio
|
||||||
|
</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatPercentage(stats.avancePromedio)}
|
||||||
|
</div>
|
||||||
|
<Progress value={stats.avancePromedio} className="mt-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Gastos Pendientes
|
||||||
|
</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(stats.gastoPendiente)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Por aprobar</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gastos Mensuales</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gastosPorMes.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={gastosPorMes}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="mes" />
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
`$${(value / 1000).toFixed(0)}k`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="gastos" fill="#0088FE" name="Gastos" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
|
No hay datos de gastos
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Distribucion por Categoria</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gastosPorCategoria.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={gastosPorCategoria}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ categoria, porcentaje }) =>
|
||||||
|
`${CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto] || categoria} ${porcentaje.toFixed(0)}%`
|
||||||
|
}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="total"
|
||||||
|
>
|
||||||
|
{gastosPorCategoria.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
|
No hay datos de categorias
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Projects */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Obras Recientes</CardTitle>
|
||||||
|
<Link
|
||||||
|
href="/obras"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Ver todas
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{obrasRecientes.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{obrasRecientes.map((obra) => (
|
||||||
|
<div
|
||||||
|
key={obra.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/obras/${obra.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{obra.nombre}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
|
||||||
|
{ESTADO_OBRA_LABELS[obra.estado]}
|
||||||
|
</Badge>
|
||||||
|
{obra.fechaFinPrevista && (
|
||||||
|
<span>
|
||||||
|
Fin previsto:{" "}
|
||||||
|
{formatDateShort(obra.fechaFinPrevista)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{formatPercentage(obra.porcentajeAvance)}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={obra.porcentajeAvance}
|
||||||
|
className="mt-1 w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<AlertTriangle className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">No tienes obras registradas</p>
|
||||||
|
<Link
|
||||||
|
href="/obras/nueva"
|
||||||
|
className="mt-2 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Crear primera obra
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/app/(dashboard)/dashboard/page.tsx
Normal file
138
src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { DashboardClient } from "./dashboard-client";
|
||||||
|
|
||||||
|
async function getDashboardData(empresaId: string) {
|
||||||
|
const [obras, gastos, obrasResumen] = await Promise.all([
|
||||||
|
prisma.obra.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nombre: true,
|
||||||
|
estado: true,
|
||||||
|
porcentajeAvance: true,
|
||||||
|
presupuestoTotal: true,
|
||||||
|
gastoTotal: true,
|
||||||
|
fechaInicio: true,
|
||||||
|
fechaFinPrevista: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
prisma.gasto.findMany({
|
||||||
|
where: { obra: { empresaId } },
|
||||||
|
select: {
|
||||||
|
monto: true,
|
||||||
|
categoria: true,
|
||||||
|
fecha: true,
|
||||||
|
estado: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.obra.aggregate({
|
||||||
|
where: { empresaId },
|
||||||
|
_count: { _all: true },
|
||||||
|
_sum: {
|
||||||
|
presupuestoTotal: true,
|
||||||
|
gastoTotal: true,
|
||||||
|
},
|
||||||
|
_avg: {
|
||||||
|
porcentajeAvance: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const obrasActivas = await prisma.obra.count({
|
||||||
|
where: {
|
||||||
|
empresaId,
|
||||||
|
estado: { in: ["EN_PROGRESO", "PLANIFICACION"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const obrasCompletadas = await prisma.obra.count({
|
||||||
|
where: { empresaId, estado: "COMPLETADA" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gastosPendientes = await prisma.gasto.aggregate({
|
||||||
|
where: {
|
||||||
|
obra: { empresaId },
|
||||||
|
estado: "PENDIENTE",
|
||||||
|
},
|
||||||
|
_sum: { monto: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gastos por mes (ultimos 6 meses)
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||||
|
|
||||||
|
const gastosMensuales = await prisma.gasto.groupBy({
|
||||||
|
by: ["fecha"],
|
||||||
|
where: {
|
||||||
|
obra: { empresaId },
|
||||||
|
fecha: { gte: sixMonthsAgo },
|
||||||
|
},
|
||||||
|
_sum: { monto: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por mes
|
||||||
|
const gastosPorMes = gastosMensuales.reduce(
|
||||||
|
(acc, g) => {
|
||||||
|
const mes = new Date(g.fecha).toLocaleDateString("es-MX", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
acc[mes] = (acc[mes] || 0) + (g._sum.monto || 0);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gastos por categoria
|
||||||
|
const gastosPorCategoria = gastos.reduce(
|
||||||
|
(acc, g) => {
|
||||||
|
acc[g.categoria] = (acc[g.categoria] || 0) + g.monto;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalGastos = Object.values(gastosPorCategoria).reduce(
|
||||||
|
(a, b) => a + b,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
obrasActivas,
|
||||||
|
presupuestoTotal: obrasResumen._sum.presupuestoTotal || 0,
|
||||||
|
gastoTotal: obrasResumen._sum.gastoTotal || 0,
|
||||||
|
avancePromedio: obrasResumen._avg.porcentajeAvance || 0,
|
||||||
|
obrasCompletadas,
|
||||||
|
gastoPendiente: gastosPendientes._sum.monto || 0,
|
||||||
|
},
|
||||||
|
obrasRecientes: obras,
|
||||||
|
gastosPorMes: Object.entries(gastosPorMes).map(([mes, gastos]) => ({
|
||||||
|
mes,
|
||||||
|
gastos,
|
||||||
|
presupuesto: 0,
|
||||||
|
})),
|
||||||
|
gastosPorCategoria: Object.entries(gastosPorCategoria).map(
|
||||||
|
([categoria, total]) => ({
|
||||||
|
categoria,
|
||||||
|
total,
|
||||||
|
porcentaje: totalGastos > 0 ? (total / totalGastos) * 100 : 0,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardData = await getDashboardData(session.user.empresaId);
|
||||||
|
|
||||||
|
return <DashboardClient data={dashboardData} />;
|
||||||
|
}
|
||||||
500
src/app/(dashboard)/finanzas/finanzas-client.tsx
Normal file
500
src/app/(dashboard)/finanzas/finanzas-client.tsx
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
MoreVertical,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatCurrency, formatDateShort } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ESTADO_GASTO_LABELS,
|
||||||
|
ESTADO_GASTO_COLORS,
|
||||||
|
CATEGORIA_GASTO_LABELS,
|
||||||
|
type EstadoGasto,
|
||||||
|
type CategoriaGasto,
|
||||||
|
type Role,
|
||||||
|
} from "@/types";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Gasto {
|
||||||
|
id: string;
|
||||||
|
concepto: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
monto: number;
|
||||||
|
fecha: Date;
|
||||||
|
categoria: CategoriaGasto;
|
||||||
|
estado: EstadoGasto;
|
||||||
|
notas: string | null;
|
||||||
|
obra: { id: string; nombre: string };
|
||||||
|
creadoPor: { nombre: string; apellido: string };
|
||||||
|
aprobadoPor: { nombre: string; apellido: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinanzasData {
|
||||||
|
gastos: Gasto[];
|
||||||
|
obras: { id: string; nombre: string; presupuestoTotal: number; gastoTotal: number }[];
|
||||||
|
resumen: {
|
||||||
|
totalGastos: number;
|
||||||
|
gastosPendientes: number;
|
||||||
|
gastosAprobados: number;
|
||||||
|
gastosPagados: number;
|
||||||
|
countPendientes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinanzasClient({
|
||||||
|
data,
|
||||||
|
userRole,
|
||||||
|
}: {
|
||||||
|
data: FinanzasData;
|
||||||
|
userRole: Role;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [filterObra, setFilterObra] = useState<string>("all");
|
||||||
|
const [filterEstado, setFilterEstado] = useState<string>("all");
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
concepto: "",
|
||||||
|
descripcion: "",
|
||||||
|
monto: "",
|
||||||
|
fecha: new Date().toISOString().split("T")[0],
|
||||||
|
categoria: "MATERIALES" as CategoriaGasto,
|
||||||
|
obraId: "",
|
||||||
|
notas: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const canApprove = ["ADMIN", "GERENTE", "CONTADOR"].includes(userRole);
|
||||||
|
|
||||||
|
const filteredGastos = data.gastos.filter((gasto) => {
|
||||||
|
if (filterObra !== "all" && gasto.obra.id !== filterObra) return false;
|
||||||
|
if (filterEstado !== "all" && gasto.estado !== filterEstado) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateGasto = async () => {
|
||||||
|
if (!formData.obraId || !formData.concepto || !formData.monto) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Completa los campos requeridos",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/gastos", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
monto: parseFloat(formData.monto),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Error al crear");
|
||||||
|
|
||||||
|
toast({ title: "Gasto registrado exitosamente" });
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setFormData({
|
||||||
|
concepto: "",
|
||||||
|
descripcion: "",
|
||||||
|
monto: "",
|
||||||
|
fecha: new Date().toISOString().split("T")[0],
|
||||||
|
categoria: "MATERIALES",
|
||||||
|
obraId: "",
|
||||||
|
notas: "",
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo registrar el gasto",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: string, estado: "APROBADO" | "RECHAZADO") => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/gastos/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ estado }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Error al actualizar");
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: estado === "APROBADO" ? "Gasto aprobado" : "Gasto rechazado",
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo actualizar el gasto",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Finanzas</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Control de gastos y presupuestos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Registrar Gasto
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo Gasto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Registra un nuevo gasto para una obra
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Obra *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.obraId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, obraId: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar obra" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data.obras.map((obra) => (
|
||||||
|
<SelectItem key={obra.id} value={obra.id}>
|
||||||
|
{obra.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Concepto *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.concepto}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, concepto: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Ej: Compra de cemento"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Monto *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.monto}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, monto: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fecha</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.fecha}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, fecha: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Categoria</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.categoria}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
categoria: value as CategoriaGasto,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(CATEGORIA_GASTO_LABELS).map(
|
||||||
|
([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notas</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notas}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, notas: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Notas adicionales..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateGasto} disabled={isCreating}>
|
||||||
|
{isCreating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Registrar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Gastos</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(data.resumen.totalGastos)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Pendientes</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-yellow-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(data.resumen.gastosPendientes)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{data.resumen.countPendientes} por aprobar
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Aprobados</CardTitle>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(data.resumen.gastosAprobados)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Pagados</CardTitle>
|
||||||
|
<CheckCircle className="h-4 w-4 text-blue-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(data.resumen.gastosPagados)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Registro de Gastos</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={filterObra} onValueChange={setFilterObra}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filtrar por obra" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas las obras</SelectItem>
|
||||||
|
{data.obras.map((obra) => (
|
||||||
|
<SelectItem key={obra.id} value={obra.id}>
|
||||||
|
{obra.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterEstado} onValueChange={setFilterEstado}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="Estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{Object.entries(ESTADO_GASTO_LABELS).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Fecha</TableHead>
|
||||||
|
<TableHead>Concepto</TableHead>
|
||||||
|
<TableHead>Obra</TableHead>
|
||||||
|
<TableHead>Categoria</TableHead>
|
||||||
|
<TableHead>Monto</TableHead>
|
||||||
|
<TableHead>Estado</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Acciones</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredGastos.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
No se encontraron gastos
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredGastos.map((gasto) => (
|
||||||
|
<TableRow key={gasto.id}>
|
||||||
|
<TableCell>{formatDateShort(gasto.fecha)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{gasto.concepto}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{gasto.creadoPor.nombre} {gasto.creadoPor.apellido}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{gasto.obra.nombre}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{CATEGORIA_GASTO_LABELS[gasto.categoria]}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{formatCurrency(gasto.monto)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
|
||||||
|
{ESTADO_GASTO_LABELS[gasto.estado]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canApprove && gasto.estado === "PENDIENTE" ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleApprove(gasto.id, "APROBADO")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleApprove(gasto.id, "RECHAZADO")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Ver detalles</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Editar</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/(dashboard)/finanzas/page.tsx
Normal file
57
src/app/(dashboard)/finanzas/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { FinanzasClient } from "./finanzas-client";
|
||||||
|
|
||||||
|
async function getFinanzasData(empresaId: string) {
|
||||||
|
const [gastos, obras, totales] = await Promise.all([
|
||||||
|
prisma.gasto.findMany({
|
||||||
|
where: { obra: { empresaId } },
|
||||||
|
include: {
|
||||||
|
obra: { select: { id: true, nombre: true } },
|
||||||
|
creadoPor: { select: { nombre: true, apellido: true } },
|
||||||
|
aprobadoPor: { select: { nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
orderBy: { fecha: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.obra.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nombre: true,
|
||||||
|
presupuestoTotal: true,
|
||||||
|
gastoTotal: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.gasto.groupBy({
|
||||||
|
by: ["estado"],
|
||||||
|
where: { obra: { empresaId } },
|
||||||
|
_sum: { monto: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumen = {
|
||||||
|
totalGastos: gastos.reduce((sum, g) => sum + g.monto, 0),
|
||||||
|
gastosPendientes:
|
||||||
|
totales.find((t) => t.estado === "PENDIENTE")?._sum.monto || 0,
|
||||||
|
gastosAprobados:
|
||||||
|
totales.find((t) => t.estado === "APROBADO")?._sum.monto || 0,
|
||||||
|
gastosPagados: totales.find((t) => t.estado === "PAGADO")?._sum.monto || 0,
|
||||||
|
countPendientes:
|
||||||
|
totales.find((t) => t.estado === "PENDIENTE")?._count || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { gastos, obras, resumen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FinanzasPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getFinanzasData(session.user.empresaId);
|
||||||
|
|
||||||
|
return <FinanzasClient data={data} userRole={session.user.role} />;
|
||||||
|
}
|
||||||
18
src/app/(dashboard)/layout.tsx
Normal file
18
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-auto bg-slate-50 p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/(dashboard)/obras/[id]/editar/page.tsx
Normal file
81
src/app/(dashboard)/obras/[id]/editar/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ObraForm } from "@/components/forms/obra-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
async function getObraWithFormData(id: string, empresaId: string) {
|
||||||
|
const [obra, clientes, supervisores] = await Promise.all([
|
||||||
|
prisma.obra.findFirst({
|
||||||
|
where: { id, empresaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nombre: true,
|
||||||
|
descripcion: true,
|
||||||
|
direccion: true,
|
||||||
|
estado: true,
|
||||||
|
fechaInicio: true,
|
||||||
|
fechaFinPrevista: true,
|
||||||
|
clienteId: true,
|
||||||
|
supervisorId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.cliente.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: { id: true, nombre: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
empresaId,
|
||||||
|
role: { in: ["ADMIN", "GERENTE", "SUPERVISOR"] },
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
select: { id: true, nombre: true, apellido: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { obra, clientes, supervisores };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditarObraPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { obra, clientes, supervisores } = await getObraWithFormData(
|
||||||
|
id,
|
||||||
|
session.user.empresaId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!obra) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/obras/${id}`}
|
||||||
|
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Volver a detalles
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Editar Obra</h2>
|
||||||
|
<p className="text-muted-foreground">{obra.nombre}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ObraForm obra={obra} clientes={clientes} supervisores={supervisores} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
490
src/app/(dashboard)/obras/[id]/obra-detail-client.tsx
Normal file
490
src/app/(dashboard)/obras/[id]/obra-detail-client.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
Edit,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Building,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
formatDate,
|
||||||
|
formatDateShort,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ESTADO_OBRA_LABELS,
|
||||||
|
ESTADO_OBRA_COLORS,
|
||||||
|
ESTADO_TAREA_LABELS,
|
||||||
|
ESTADO_GASTO_LABELS,
|
||||||
|
ESTADO_GASTO_COLORS,
|
||||||
|
CATEGORIA_GASTO_LABELS,
|
||||||
|
type EstadoObra,
|
||||||
|
type EstadoTarea,
|
||||||
|
type EstadoGasto,
|
||||||
|
type CategoriaGasto,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
interface ObraDetailProps {
|
||||||
|
obra: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
direccion: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
fechaFinReal: Date | null;
|
||||||
|
cliente: { id: string; nombre: string; email: string | null } | null;
|
||||||
|
supervisor: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
apellido: string;
|
||||||
|
email: string | null;
|
||||||
|
} | null;
|
||||||
|
fases: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
orden: number;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
tareas: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
estado: EstadoTarea;
|
||||||
|
prioridad: number;
|
||||||
|
asignado: { nombre: string; apellido: string } | null;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
presupuestos: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
total: number;
|
||||||
|
aprobado: boolean;
|
||||||
|
partidas: {
|
||||||
|
id: string;
|
||||||
|
codigo: string;
|
||||||
|
descripcion: string;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
gastos: {
|
||||||
|
id: string;
|
||||||
|
concepto: string;
|
||||||
|
monto: number;
|
||||||
|
fecha: Date;
|
||||||
|
categoria: CategoriaGasto;
|
||||||
|
estado: EstadoGasto;
|
||||||
|
creadoPor: { nombre: string; apellido: string };
|
||||||
|
}[];
|
||||||
|
registrosAvance: {
|
||||||
|
id: string;
|
||||||
|
descripcion: string;
|
||||||
|
porcentaje: number;
|
||||||
|
fotos: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
registradoPor: { nombre: string; apellido: string };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObraDetailClient({ obra }: ObraDetailProps) {
|
||||||
|
const variacion = obra.presupuestoTotal - obra.gastoTotal;
|
||||||
|
const variacionPorcentaje =
|
||||||
|
obra.presupuestoTotal > 0
|
||||||
|
? ((obra.gastoTotal / obra.presupuestoTotal) * 100).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/obras"
|
||||||
|
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Volver a obras
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">{obra.nombre}</h2>
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
|
||||||
|
{ESTADO_OBRA_LABELS[obra.estado]}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex items-center text-sm text-muted-foreground">
|
||||||
|
<MapPin className="h-4 w-4 mr-1" />
|
||||||
|
{obra.direccion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/obras/${obra.id}/editar`}>
|
||||||
|
<Button>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress and Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatPercentage(obra.porcentajeAvance)}
|
||||||
|
</div>
|
||||||
|
<Progress value={obra.porcentajeAvance} className="mt-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Presupuesto</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(obra.presupuestoTotal)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total aprobado</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Gastado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(obra.gastoTotal)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{variacionPorcentaje}% del presupuesto
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Variacion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${variacion >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{variacion >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(variacion)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{variacion >= 0 ? "Bajo presupuesto" : "Sobre presupuesto"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="general" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
|
<TabsTrigger value="cronograma">Cronograma</TabsTrigger>
|
||||||
|
<TabsTrigger value="presupuesto">Presupuesto</TabsTrigger>
|
||||||
|
<TabsTrigger value="gastos">Gastos</TabsTrigger>
|
||||||
|
<TabsTrigger value="avances">Avances</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="general" className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informacion del Proyecto</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{obra.descripcion && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Descripcion
|
||||||
|
</p>
|
||||||
|
<p>{obra.descripcion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Fecha Inicio
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{obra.fechaInicio
|
||||||
|
? formatDate(obra.fechaInicio)
|
||||||
|
: "No definida"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Fecha Fin Prevista
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{obra.fechaFinPrevista
|
||||||
|
? formatDate(obra.fechaFinPrevista)
|
||||||
|
: "No definida"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Equipo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||||
|
<Building className="h-4 w-4" />
|
||||||
|
Cliente
|
||||||
|
</p>
|
||||||
|
<p>{obra.cliente?.nombre || "Sin cliente asignado"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Supervisor
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{obra.supervisor
|
||||||
|
? `${obra.supervisor.nombre} ${obra.supervisor.apellido}`
|
||||||
|
: "Sin supervisor asignado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cronograma" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Fases y Tareas</h3>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Agregar Fase
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{obra.fases.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
No hay fases definidas para esta obra
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{obra.fases.map((fase) => (
|
||||||
|
<Card key={fase.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">{fase.nombre}</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatPercentage(fase.porcentajeAvance)}
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={fase.porcentajeAvance}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fase.descripcion && (
|
||||||
|
<CardDescription>{fase.descripcion}</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{fase.tareas.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sin tareas asignadas
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fase.tareas.map((tarea) => (
|
||||||
|
<div
|
||||||
|
key={tarea.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{tarea.estado === "COMPLETADA" ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : tarea.estado === "BLOQUEADA" ? (
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{tarea.nombre}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{tarea.asignado && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{tarea.asignado.nombre}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ESTADO_TAREA_LABELS[tarea.estado]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="presupuesto" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Presupuestos</h3>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Nuevo Presupuesto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{obra.presupuestos.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
No hay presupuestos definidos
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{obra.presupuestos.map((presupuesto) => (
|
||||||
|
<Card key={presupuesto.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{presupuesto.nombre}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{presupuesto.partidas.length} partidas
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-bold">
|
||||||
|
{formatCurrency(presupuesto.total)}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={presupuesto.aprobado ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="gastos" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Gastos Recientes</h3>
|
||||||
|
<Link href={`/finanzas?obra=${obra.id}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Ver todos los gastos
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{obra.gastos.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
No hay gastos registrados
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{obra.gastos.map((gasto) => (
|
||||||
|
<Card key={gasto.id}>
|
||||||
|
<CardContent className="flex items-center justify-between py-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{gasto.concepto}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{CATEGORIA_GASTO_LABELS[gasto.categoria]} -{" "}
|
||||||
|
{formatDateShort(gasto.fecha)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold">{formatCurrency(gasto.monto)}</p>
|
||||||
|
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
|
||||||
|
{ESTADO_GASTO_LABELS[gasto.estado]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="avances" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Registros de Avance</h3>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Registrar Avance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{obra.registrosAvance.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
No hay registros de avance
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{obra.registrosAvance.map((registro) => (
|
||||||
|
<Card key={registro.id}>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{registro.descripcion}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{registro.registradoPor.nombre}{" "}
|
||||||
|
{registro.registradoPor.apellido} -{" "}
|
||||||
|
{formatDate(registro.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{formatPercentage(registro.porcentaje)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/(dashboard)/obras/[id]/page.tsx
Normal file
66
src/app/(dashboard)/obras/[id]/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ObraDetailClient } from "./obra-detail-client";
|
||||||
|
|
||||||
|
async function getObra(id: string, empresaId: string) {
|
||||||
|
return await prisma.obra.findFirst({
|
||||||
|
where: { id, empresaId },
|
||||||
|
include: {
|
||||||
|
cliente: true,
|
||||||
|
supervisor: {
|
||||||
|
select: { id: true, nombre: true, apellido: true, email: true },
|
||||||
|
},
|
||||||
|
fases: {
|
||||||
|
include: {
|
||||||
|
tareas: {
|
||||||
|
include: {
|
||||||
|
asignado: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
orderBy: { prioridad: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
},
|
||||||
|
presupuestos: {
|
||||||
|
include: { partidas: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
|
gastos: {
|
||||||
|
orderBy: { fecha: "desc" },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
creadoPor: { select: { nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registrosAvance: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
registradoPor: { select: { nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ObraDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obra = await getObra(id, session.user.empresaId);
|
||||||
|
|
||||||
|
if (!obra) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ObraDetailClient obra={obra} />;
|
||||||
|
}
|
||||||
56
src/app/(dashboard)/obras/nueva/page.tsx
Normal file
56
src/app/(dashboard)/obras/nueva/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ObraForm } from "@/components/forms/obra-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
async function getFormData(empresaId: string) {
|
||||||
|
const [clientes, supervisores] = await Promise.all([
|
||||||
|
prisma.cliente.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: { id: true, nombre: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
empresaId,
|
||||||
|
role: { in: ["ADMIN", "GERENTE", "SUPERVISOR"] },
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
select: { id: true, nombre: true, apellido: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { clientes, supervisores };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NuevaObraPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientes, supervisores } = await getFormData(session.user.empresaId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/obras"
|
||||||
|
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Volver a obras
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Nueva Obra</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Crea un nuevo proyecto de construccion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ObraForm clientes={clientes} supervisores={supervisores} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/app/(dashboard)/obras/obras-client.tsx
Normal file
271
src/app/(dashboard)/obras/obras-client.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
formatDateShort,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ESTADO_OBRA_LABELS,
|
||||||
|
ESTADO_OBRA_COLORS,
|
||||||
|
type EstadoObra,
|
||||||
|
} from "@/types";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Obra {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
direccion: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
cliente: { id: string; nombre: string } | null;
|
||||||
|
supervisor: { id: string; nombre: string; apellido: string } | null;
|
||||||
|
_count: { fases: number; gastos: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObrasClient({ obras }: { obras: Obra[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const filteredObras = obras.filter(
|
||||||
|
(obra) =>
|
||||||
|
obra.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
obra.direccion.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/obras/${deleteId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Error al eliminar");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Obra eliminada",
|
||||||
|
description: "La obra ha sido eliminada exitosamente",
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo eliminar la obra",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Obras</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Gestiona tus proyectos de construccion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/obras/nueva">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nueva Obra
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar obras..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredObras.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{obras.length === 0
|
||||||
|
? "No tienes obras registradas"
|
||||||
|
: "No se encontraron resultados"}
|
||||||
|
</p>
|
||||||
|
{obras.length === 0 && (
|
||||||
|
<Link href="/obras/nueva">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Crear primera obra
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredObras.map((obra) => (
|
||||||
|
<Card key={obra.id} className="overflow-hidden">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="line-clamp-1">{obra.nombre}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span className="line-clamp-1">{obra.direccion}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/obras/${obra.id}`}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver detalles
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/obras/${obra.id}/editar`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600"
|
||||||
|
onClick={() => setDeleteId(obra.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
|
||||||
|
{ESTADO_OBRA_LABELS[obra.estado]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{formatPercentage(obra.porcentajeAvance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={obra.porcentajeAvance} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Presupuesto</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatCurrency(obra.presupuestoTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Gastado</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatCurrency(obra.gastoTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
{obra.fechaInicio && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDateShort(obra.fechaInicio)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{obra.supervisor && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{obra.supervisor.nombre}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Eliminar obra</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Esta accion no se puede deshacer. Se eliminaran todos los datos
|
||||||
|
relacionados con esta obra (fases, gastos, presupuestos, etc.).
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{isDeleting ? "Eliminando..." : "Eliminar"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/(dashboard)/obras/page.tsx
Normal file
29
src/app/(dashboard)/obras/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ObrasClient } from "./obras-client";
|
||||||
|
|
||||||
|
async function getObras(empresaId: string) {
|
||||||
|
return await prisma.obra.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
include: {
|
||||||
|
cliente: { select: { id: true, nombre: true } },
|
||||||
|
supervisor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
_count: {
|
||||||
|
select: { fases: true, gastos: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ObrasPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obras = await getObras(session.user.empresaId);
|
||||||
|
|
||||||
|
return <ObrasClient obras={obras} />;
|
||||||
|
}
|
||||||
635
src/app/(dashboard)/recursos/materiales/materiales-client.tsx
Normal file
635
src/app/(dashboard)/recursos/materiales/materiales-client.tsx
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
MoreVertical,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
ArrowUpCircle,
|
||||||
|
ArrowDownCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
import { UNIDAD_MEDIDA_LABELS, type UnidadMedida } from "@/types";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Material {
|
||||||
|
id: string;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
unidad: UnidadMedida;
|
||||||
|
precioUnitario: number;
|
||||||
|
stockMinimo: number;
|
||||||
|
stockActual: number;
|
||||||
|
ubicacion: string | null;
|
||||||
|
activo: boolean;
|
||||||
|
movimientos: {
|
||||||
|
id: string;
|
||||||
|
tipo: string;
|
||||||
|
cantidad: number;
|
||||||
|
motivo: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
obra: { nombre: string } | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaterialesData {
|
||||||
|
materiales: Material[];
|
||||||
|
obras: { id: string; nombre: string }[];
|
||||||
|
alertas: Material[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialesClient({ data }: { data: MaterialesData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isMoving, setIsMoving] = useState(false);
|
||||||
|
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
|
||||||
|
const [isMovimientoDialogOpen, setIsMovimientoDialogOpen] = useState(false);
|
||||||
|
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const [materialForm, setMaterialForm] = useState({
|
||||||
|
codigo: "",
|
||||||
|
nombre: "",
|
||||||
|
descripcion: "",
|
||||||
|
unidad: "UNIDAD" as UnidadMedida,
|
||||||
|
precioUnitario: "",
|
||||||
|
stockMinimo: "0",
|
||||||
|
ubicacion: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [movimientoForm, setMovimientoForm] = useState({
|
||||||
|
tipo: "ENTRADA" as "ENTRADA" | "SALIDA" | "AJUSTE",
|
||||||
|
cantidad: "",
|
||||||
|
motivo: "",
|
||||||
|
obraId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetMaterialForm = () => {
|
||||||
|
setMaterialForm({
|
||||||
|
codigo: "",
|
||||||
|
nombre: "",
|
||||||
|
descripcion: "",
|
||||||
|
unidad: "UNIDAD",
|
||||||
|
precioUnitario: "",
|
||||||
|
stockMinimo: "0",
|
||||||
|
ubicacion: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateMaterial = async () => {
|
||||||
|
if (!materialForm.codigo || !materialForm.nombre || !materialForm.precioUnitario) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Completa los campos requeridos",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/materiales", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...materialForm,
|
||||||
|
precioUnitario: parseFloat(materialForm.precioUnitario),
|
||||||
|
stockMinimo: parseFloat(materialForm.stockMinimo),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Error al crear");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Material creado exitosamente" });
|
||||||
|
setIsMaterialDialogOpen(false);
|
||||||
|
resetMaterialForm();
|
||||||
|
router.refresh();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error instanceof Error ? error.message : "No se pudo crear el material",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMovimiento = async () => {
|
||||||
|
if (!selectedMaterial || !movimientoForm.cantidad) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Ingresa la cantidad",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMoving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/materiales/movimiento", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
materialId: selectedMaterial.id,
|
||||||
|
tipo: movimientoForm.tipo,
|
||||||
|
cantidad: parseFloat(movimientoForm.cantidad),
|
||||||
|
motivo: movimientoForm.motivo,
|
||||||
|
obraId: movimientoForm.obraId || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Error al registrar movimiento");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Movimiento registrado exitosamente" });
|
||||||
|
setIsMovimientoDialogOpen(false);
|
||||||
|
setSelectedMaterial(null);
|
||||||
|
setMovimientoForm({ tipo: "ENTRADA", cantidad: "", motivo: "", obraId: "" });
|
||||||
|
router.refresh();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error instanceof Error ? error.message : "No se pudo registrar el movimiento",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsMoving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/materiales/${deleteId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Error al eliminar");
|
||||||
|
|
||||||
|
toast({ title: "Material eliminado exitosamente" });
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo eliminar el material",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMovimientoDialog = (material: Material, tipo: "ENTRADA" | "SALIDA") => {
|
||||||
|
setSelectedMaterial(material);
|
||||||
|
setMovimientoForm({ tipo, cantidad: "", motivo: "", obraId: "" });
|
||||||
|
setIsMovimientoDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/recursos"
|
||||||
|
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Volver a recursos
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Inventario de Materiales</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Gestiona el stock y movimientos de materiales
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isMaterialDialogOpen} onOpenChange={setIsMaterialDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Material
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo Material</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Agrega un nuevo material al inventario
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Codigo *</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.codigo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, codigo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="MAT-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Unidad</Label>
|
||||||
|
<Select
|
||||||
|
value={materialForm.unidad}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setMaterialForm({ ...materialForm, unidad: value as UnidadMedida })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nombre *</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.nombre}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, nombre: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Cemento Portland"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Descripcion</Label>
|
||||||
|
<Textarea
|
||||||
|
value={materialForm.descripcion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, descripcion: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Descripcion del material..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Precio Unitario *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={materialForm.precioUnitario}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, precioUnitario: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Stock Minimo</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={materialForm.stockMinimo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, stockMinimo: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ubicacion</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.ubicacion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, ubicacion: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Bodega A - Estante 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsMaterialDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateMaterial} disabled={isCreating}>
|
||||||
|
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Crear Material
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{data.alertas.length > 0 && (
|
||||||
|
<Card className="border-yellow-200 bg-yellow-50">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-yellow-800">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
Alertas de Stock Bajo
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.alertas.map((m) => (
|
||||||
|
<Badge key={m.id} variant="outline" className="bg-white">
|
||||||
|
{m.nombre}: {m.stockActual} / {m.stockMinimo} {UNIDAD_MEDIDA_LABELS[m.unidad]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Materiales</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data.materiales.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Con Stock Bajo</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">{data.alertas.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Valor Total Inventario</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(
|
||||||
|
data.materiales.reduce((sum, m) => sum + m.stockActual * m.precioUnitario, 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Catalogo de Materiales</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Codigo</TableHead>
|
||||||
|
<TableHead>Nombre</TableHead>
|
||||||
|
<TableHead>Unidad</TableHead>
|
||||||
|
<TableHead>Precio Unit.</TableHead>
|
||||||
|
<TableHead>Stock Actual</TableHead>
|
||||||
|
<TableHead>Stock Min.</TableHead>
|
||||||
|
<TableHead>Estado</TableHead>
|
||||||
|
<TableHead>Ubicacion</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Acciones</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.materiales.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="h-24 text-center">
|
||||||
|
No hay materiales registrados
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.materiales.map((material) => (
|
||||||
|
<TableRow key={material.id}>
|
||||||
|
<TableCell className="font-mono">{material.codigo}</TableCell>
|
||||||
|
<TableCell className="font-medium">{material.nombre}</TableCell>
|
||||||
|
<TableCell>{UNIDAD_MEDIDA_LABELS[material.unidad]}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(material.precioUnitario)}</TableCell>
|
||||||
|
<TableCell className="font-medium">{material.stockActual}</TableCell>
|
||||||
|
<TableCell>{material.stockMinimo}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{material.stockActual <= material.stockMinimo ? (
|
||||||
|
<Badge variant="destructive">Bajo</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700">OK</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{material.ubicacion || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Entrada"
|
||||||
|
onClick={() => openMovimientoDialog(material, "ENTRADA")}
|
||||||
|
>
|
||||||
|
<ArrowDownCircle className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Salida"
|
||||||
|
onClick={() => openMovimientoDialog(material, "SALIDA")}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600"
|
||||||
|
onClick={() => setDeleteId(material.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Movimiento Dialog */}
|
||||||
|
<Dialog open={isMovimientoDialogOpen} onOpenChange={setIsMovimientoDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{movimientoForm.tipo === "ENTRADA" ? "Entrada de Material" : "Salida de Material"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedMaterial?.nombre} - Stock actual: {selectedMaterial?.stockActual}{" "}
|
||||||
|
{selectedMaterial && UNIDAD_MEDIDA_LABELS[selectedMaterial.unidad]}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cantidad *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={movimientoForm.cantidad}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMovimientoForm({ ...movimientoForm, cantidad: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{movimientoForm.tipo === "SALIDA" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Obra destino</Label>
|
||||||
|
<Select
|
||||||
|
value={movimientoForm.obraId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setMovimientoForm({ ...movimientoForm, obraId: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar obra (opcional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data.obras.map((obra) => (
|
||||||
|
<SelectItem key={obra.id} value={obra.id}>
|
||||||
|
{obra.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Motivo / Notas</Label>
|
||||||
|
<Textarea
|
||||||
|
value={movimientoForm.motivo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMovimientoForm({ ...movimientoForm, motivo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Descripcion del movimiento..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsMovimientoDialogOpen(false)}
|
||||||
|
disabled={isMoving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleMovimiento} disabled={isMoving}>
|
||||||
|
{isMoving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Registrar {movimientoForm.tipo === "ENTRADA" ? "Entrada" : "Salida"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Eliminar material</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Esta accion no se puede deshacer. Se eliminara el material y todo su historial de movimientos.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{isDeleting ? "Eliminando..." : "Eliminar"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/(dashboard)/recursos/materiales/page.tsx
Normal file
42
src/app/(dashboard)/recursos/materiales/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { MaterialesClient } from "./materiales-client";
|
||||||
|
|
||||||
|
async function getMateriales(empresaId: string) {
|
||||||
|
const materiales = await prisma.material.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
include: {
|
||||||
|
movimientos: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
obra: { select: { nombre: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const obras = await prisma.obra.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: { id: true, nombre: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertas = materiales.filter(
|
||||||
|
(m) => m.activo && m.stockActual <= m.stockMinimo
|
||||||
|
);
|
||||||
|
|
||||||
|
return { materiales, obras, alertas };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MaterialesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getMateriales(session.user.empresaId);
|
||||||
|
|
||||||
|
return <MaterialesClient data={data} />;
|
||||||
|
}
|
||||||
39
src/app/(dashboard)/recursos/page.tsx
Normal file
39
src/app/(dashboard)/recursos/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { RecursosClient } from "./recursos-client";
|
||||||
|
|
||||||
|
async function getRecursosData(empresaId: string) {
|
||||||
|
const [materiales, empleados, subcontratistas] = await Promise.all([
|
||||||
|
prisma.material.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.empleado.findMany({
|
||||||
|
where: { empresaId, activo: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.subcontratista.findMany({
|
||||||
|
where: { empresaId, activo: true },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get materials with low stock
|
||||||
|
const alertas = materiales.filter(
|
||||||
|
(m) => m.stockActual <= m.stockMinimo && m.activo
|
||||||
|
);
|
||||||
|
|
||||||
|
return { materiales, empleados, subcontratistas, alertas };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RecursosPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRecursosData(session.user.empresaId);
|
||||||
|
|
||||||
|
return <RecursosClient data={data} />;
|
||||||
|
}
|
||||||
482
src/app/(dashboard)/recursos/recursos-client.tsx
Normal file
482
src/app/(dashboard)/recursos/recursos-client.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Package,
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
import { UNIDAD_MEDIDA_LABELS, type UnidadMedida } from "@/types";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Material {
|
||||||
|
id: string;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
unidad: UnidadMedida;
|
||||||
|
precioUnitario: number;
|
||||||
|
stockMinimo: number;
|
||||||
|
stockActual: number;
|
||||||
|
ubicacion: string | null;
|
||||||
|
activo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Empleado {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
apellido: string;
|
||||||
|
documento: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
puesto: string;
|
||||||
|
salarioBase: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subcontratista {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
especialidad: string;
|
||||||
|
telefono: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecursosData {
|
||||||
|
materiales: Material[];
|
||||||
|
empleados: Empleado[];
|
||||||
|
subcontratistas: Subcontratista[];
|
||||||
|
alertas: Material[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecursosClient({ data }: { data: RecursosData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
|
||||||
|
const [materialForm, setMaterialForm] = useState({
|
||||||
|
codigo: "",
|
||||||
|
nombre: "",
|
||||||
|
descripcion: "",
|
||||||
|
unidad: "UNIDAD" as UnidadMedida,
|
||||||
|
precioUnitario: "",
|
||||||
|
stockMinimo: "0",
|
||||||
|
ubicacion: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateMaterial = async () => {
|
||||||
|
if (!materialForm.codigo || !materialForm.nombre || !materialForm.precioUnitario) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Completa los campos requeridos",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/materiales", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...materialForm,
|
||||||
|
precioUnitario: parseFloat(materialForm.precioUnitario),
|
||||||
|
stockMinimo: parseFloat(materialForm.stockMinimo),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Error al crear");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Material creado exitosamente" });
|
||||||
|
setIsMaterialDialogOpen(false);
|
||||||
|
setMaterialForm({
|
||||||
|
codigo: "",
|
||||||
|
nombre: "",
|
||||||
|
descripcion: "",
|
||||||
|
unidad: "UNIDAD",
|
||||||
|
precioUnitario: "",
|
||||||
|
stockMinimo: "0",
|
||||||
|
ubicacion: "",
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error instanceof Error ? error.message : "No se pudo crear el material",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Recursos</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Gestiona materiales, personal y subcontratistas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{data.alertas.length > 0 && (
|
||||||
|
<Card className="border-yellow-200 bg-yellow-50">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-yellow-800">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
Alertas de Inventario
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
{data.alertas.length} materiales con stock bajo o agotado
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{data.alertas.slice(0, 5).map((m) => (
|
||||||
|
<Badge key={m.id} variant="outline" className="bg-white">
|
||||||
|
{m.nombre}: {m.stockActual} {UNIDAD_MEDIDA_LABELS[m.unidad]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Materiales</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data.materiales.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{data.alertas.length} con stock bajo
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Personal</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data.empleados.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Empleados activos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Subcontratistas</CardTitle>
|
||||||
|
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data.subcontratistas.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Proveedores activos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="materiales" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="materiales">Materiales</TabsTrigger>
|
||||||
|
<TabsTrigger value="personal">Personal</TabsTrigger>
|
||||||
|
<TabsTrigger value="subcontratistas">Subcontratistas</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="materiales" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Catalogo de Materiales</h3>
|
||||||
|
<Dialog open={isMaterialDialogOpen} onOpenChange={setIsMaterialDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Material
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo Material</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Agrega un nuevo material al catalogo
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Codigo *</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.codigo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, codigo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="MAT-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Unidad</Label>
|
||||||
|
<Select
|
||||||
|
value={materialForm.unidad}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setMaterialForm({ ...materialForm, unidad: value as UnidadMedida })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nombre *</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.nombre}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, nombre: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Cemento Portland"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Precio Unitario *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={materialForm.precioUnitario}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, precioUnitario: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Stock Minimo</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={materialForm.stockMinimo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, stockMinimo: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ubicacion</Label>
|
||||||
|
<Input
|
||||||
|
value={materialForm.ubicacion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialForm({ ...materialForm, ubicacion: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Bodega A - Estante 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsMaterialDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateMaterial} disabled={isCreating}>
|
||||||
|
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Crear Material
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Codigo</TableHead>
|
||||||
|
<TableHead>Nombre</TableHead>
|
||||||
|
<TableHead>Unidad</TableHead>
|
||||||
|
<TableHead>Precio</TableHead>
|
||||||
|
<TableHead>Stock</TableHead>
|
||||||
|
<TableHead>Estado</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.materiales.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
No hay materiales registrados
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.materiales.map((material) => (
|
||||||
|
<TableRow key={material.id}>
|
||||||
|
<TableCell className="font-mono">{material.codigo}</TableCell>
|
||||||
|
<TableCell>{material.nombre}</TableCell>
|
||||||
|
<TableCell>{UNIDAD_MEDIDA_LABELS[material.unidad]}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(material.precioUnitario)}</TableCell>
|
||||||
|
<TableCell>{material.stockActual}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{material.stockActual <= material.stockMinimo ? (
|
||||||
|
<Badge variant="destructive">Bajo</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">OK</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="personal" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Personal</h3>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Empleado
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nombre</TableHead>
|
||||||
|
<TableHead>Puesto</TableHead>
|
||||||
|
<TableHead>Telefono</TableHead>
|
||||||
|
<TableHead>Salario Base</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.empleados.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="h-24 text-center">
|
||||||
|
No hay empleados registrados
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.empleados.map((empleado) => (
|
||||||
|
<TableRow key={empleado.id}>
|
||||||
|
<TableCell>
|
||||||
|
{empleado.nombre} {empleado.apellido}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{empleado.puesto}</TableCell>
|
||||||
|
<TableCell>{empleado.telefono || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{empleado.salarioBase ? formatCurrency(empleado.salarioBase) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="subcontratistas" className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold">Subcontratistas</h3>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Subcontratista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nombre</TableHead>
|
||||||
|
<TableHead>Especialidad</TableHead>
|
||||||
|
<TableHead>Telefono</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.subcontratistas.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="h-24 text-center">
|
||||||
|
No hay subcontratistas registrados
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.subcontratistas.map((sub) => (
|
||||||
|
<TableRow key={sub.id}>
|
||||||
|
<TableCell>{sub.nombre}</TableCell>
|
||||||
|
<TableCell>{sub.especialidad}</TableCell>
|
||||||
|
<TableCell>{sub.telefono || "-"}</TableCell>
|
||||||
|
<TableCell>{sub.email || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/app/(dashboard)/reportes/page.tsx
Normal file
84
src/app/(dashboard)/reportes/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ReportesClient } from "./reportes-client";
|
||||||
|
|
||||||
|
async function getReportesData(empresaId: string) {
|
||||||
|
const [obras, gastosPorCategoria, gastosPorMes, presupuestoVsReal] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.obra.findMany({
|
||||||
|
where: { empresaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nombre: true,
|
||||||
|
estado: true,
|
||||||
|
presupuestoTotal: true,
|
||||||
|
gastoTotal: true,
|
||||||
|
porcentajeAvance: true,
|
||||||
|
fechaInicio: true,
|
||||||
|
fechaFinPrevista: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.gasto.groupBy({
|
||||||
|
by: ["categoria"],
|
||||||
|
where: { obra: { empresaId } },
|
||||||
|
_sum: { monto: true },
|
||||||
|
}),
|
||||||
|
prisma.gasto.findMany({
|
||||||
|
where: { obra: { empresaId } },
|
||||||
|
select: {
|
||||||
|
monto: true,
|
||||||
|
fecha: true,
|
||||||
|
categoria: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.obra.findMany({
|
||||||
|
where: {
|
||||||
|
empresaId,
|
||||||
|
presupuestoTotal: { gt: 0 },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
nombre: true,
|
||||||
|
presupuestoTotal: true,
|
||||||
|
gastoTotal: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process gastos por mes
|
||||||
|
const gastosMensuales = gastosPorMes.reduce(
|
||||||
|
(acc, g) => {
|
||||||
|
const mes = new Date(g.fecha).toLocaleDateString("es-MX", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
acc[mes] = (acc[mes] || 0) + g.monto;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
obras,
|
||||||
|
gastosPorCategoria: gastosPorCategoria.map((g) => ({
|
||||||
|
categoria: g.categoria,
|
||||||
|
total: g._sum.monto || 0,
|
||||||
|
})),
|
||||||
|
gastosMensuales: Object.entries(gastosMensuales).map(([mes, total]) => ({
|
||||||
|
mes,
|
||||||
|
total,
|
||||||
|
})),
|
||||||
|
presupuestoVsReal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return <div>Error: No se encontro la empresa</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getReportesData(session.user.empresaId);
|
||||||
|
|
||||||
|
return <ReportesClient data={data} />;
|
||||||
|
}
|
||||||
366
src/app/(dashboard)/reportes/reportes-client.tsx
Normal file
366
src/app/(dashboard)/reportes/reportes-client.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
|
import { formatCurrency, formatPercentage } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
CATEGORIA_GASTO_LABELS,
|
||||||
|
ESTADO_OBRA_LABELS,
|
||||||
|
type CategoriaGasto,
|
||||||
|
type EstadoObra,
|
||||||
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
} from "recharts";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ReportesData {
|
||||||
|
obras: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
}[];
|
||||||
|
gastosPorCategoria: {
|
||||||
|
categoria: CategoriaGasto;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
gastosMensuales: {
|
||||||
|
mes: string;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
presupuestoVsReal: {
|
||||||
|
nombre: string;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884d8",
|
||||||
|
"#82ca9d",
|
||||||
|
"#ffc658",
|
||||||
|
"#ff7300",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportesClient({ data }: { data: ReportesData }) {
|
||||||
|
const [selectedObra, setSelectedObra] = useState<string>("all");
|
||||||
|
|
||||||
|
const totalPresupuesto = data.obras.reduce(
|
||||||
|
(sum, o) => sum + o.presupuestoTotal,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalGastado = data.obras.reduce((sum, o) => sum + o.gastoTotal, 0);
|
||||||
|
const variacion = totalPresupuesto - totalGastado;
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
const headers = [
|
||||||
|
"Obra",
|
||||||
|
"Estado",
|
||||||
|
"Presupuesto",
|
||||||
|
"Gastado",
|
||||||
|
"Variacion",
|
||||||
|
"Avance",
|
||||||
|
];
|
||||||
|
const rows = data.obras.map((o) => [
|
||||||
|
o.nombre,
|
||||||
|
ESTADO_OBRA_LABELS[o.estado],
|
||||||
|
o.presupuestoTotal,
|
||||||
|
o.gastoTotal,
|
||||||
|
o.presupuestoTotal - o.gastoTotal,
|
||||||
|
`${o.porcentajeAvance}%`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [headers.join(","), ...rows.map((r) => r.join(","))].join(
|
||||||
|
"\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `reporte-obras-${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Reportes</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Analisis y exportacion de datos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={exportToCSV}>
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Exportar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Presupuesto Total
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(totalPresupuesto)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Gastado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(totalGastado)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Variacion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${variacion >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{variacion >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(variacion)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">% Ejecutado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{totalPresupuesto > 0
|
||||||
|
? formatPercentage((totalGastado / totalPresupuesto) * 100)
|
||||||
|
: "0%"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Presupuesto vs Real por Obra</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Comparativo de presupuesto y gastos reales
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.presupuestoVsReal.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data.presupuestoVsReal}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="nombre"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
interval={0}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="presupuestoTotal"
|
||||||
|
fill="#0088FE"
|
||||||
|
name="Presupuesto"
|
||||||
|
/>
|
||||||
|
<Bar dataKey="gastoTotal" fill="#00C49F" name="Gastado" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gastos por Categoria</CardTitle>
|
||||||
|
<CardDescription>Distribucion de gastos</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.gastosPorCategoria.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data.gastosPorCategoria.map((g) => ({
|
||||||
|
...g,
|
||||||
|
name: CATEGORIA_GASTO_LABELS[g.categoria],
|
||||||
|
}))}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="total"
|
||||||
|
>
|
||||||
|
{data.gastosPorCategoria.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tendencia de Gastos Mensuales</CardTitle>
|
||||||
|
<CardDescription>Evolucion de gastos en el tiempo</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.gastosMensuales.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data.gastosMensuales}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="mes" />
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
stroke="#0088FE"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Gastos"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Resumen por Obra</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-2">Obra</th>
|
||||||
|
<th className="text-left p-2">Estado</th>
|
||||||
|
<th className="text-right p-2">Presupuesto</th>
|
||||||
|
<th className="text-right p-2">Gastado</th>
|
||||||
|
<th className="text-right p-2">Variacion</th>
|
||||||
|
<th className="text-right p-2">Avance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.obras.map((obra) => {
|
||||||
|
const obraVariacion = obra.presupuestoTotal - obra.gastoTotal;
|
||||||
|
return (
|
||||||
|
<tr key={obra.id} className="border-b">
|
||||||
|
<td className="p-2 font-medium">{obra.nombre}</td>
|
||||||
|
<td className="p-2">{ESTADO_OBRA_LABELS[obra.estado]}</td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
{formatCurrency(obra.presupuestoTotal)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
{formatCurrency(obra.gastoTotal)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`p-2 text-right ${obraVariacion >= 0 ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{obraVariacion >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(obraVariacion)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
{formatPercentage(obra.porcentajeAvance)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
64
src/app/api/auth/register/route.ts
Normal file
64
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { registerSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = registerSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: validatedData.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "El correo electronico ya esta registrado" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(validatedData.password, 12);
|
||||||
|
|
||||||
|
// Create empresa and user in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create the company
|
||||||
|
const empresa = await tx.empresa.create({
|
||||||
|
data: {
|
||||||
|
nombre: validatedData.empresaNombre,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the admin user
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: validatedData.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
nombre: validatedData.nombre,
|
||||||
|
apellido: validatedData.apellido,
|
||||||
|
role: "ADMIN",
|
||||||
|
empresaId: empresa.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { empresa, user };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Usuario creado exitosamente",
|
||||||
|
userId: result.user.id,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Registration error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al crear el usuario" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/app/api/gastos/[id]/route.ts
Normal file
166
src/app/api/gastos/[id]/route.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const gasto = await prisma.gasto.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
obra: { empresaId: session.user.empresaId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
obra: { select: { id: true, nombre: true } },
|
||||||
|
partida: true,
|
||||||
|
creadoPor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
aprobadoPor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gasto) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Gasto no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(gasto);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching gasto:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener el gasto" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId || !session.user.id) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Verify the gasto belongs to the user's empresa
|
||||||
|
const existingGasto = await prisma.gasto.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
obra: { empresaId: session.user.empresaId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingGasto) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Gasto no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle approval/rejection
|
||||||
|
if (body.estado && ["APROBADO", "RECHAZADO"].includes(body.estado)) {
|
||||||
|
// Check if user has permission to approve
|
||||||
|
if (!["ADMIN", "GERENTE", "CONTADOR"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No tienes permisos para aprobar gastos" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gasto = await prisma.gasto.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
estado: body.estado,
|
||||||
|
aprobadoPorId: session.user.id,
|
||||||
|
fechaAprobacion: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(gasto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular update
|
||||||
|
const gasto = await prisma.gasto.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
concepto: body.concepto,
|
||||||
|
descripcion: body.descripcion,
|
||||||
|
monto: body.monto,
|
||||||
|
fecha: body.fecha ? new Date(body.fecha) : undefined,
|
||||||
|
categoria: body.categoria,
|
||||||
|
notas: body.notas,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(gasto);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating gasto:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al actualizar el gasto" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existingGasto = await prisma.gasto.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
obra: { empresaId: session.user.empresaId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingGasto) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Gasto no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update obra gastoTotal before deleting
|
||||||
|
await prisma.obra.update({
|
||||||
|
where: { id: existingGasto.obraId },
|
||||||
|
data: {
|
||||||
|
gastoTotal: { decrement: existingGasto.monto },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.gasto.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Gasto eliminado exitosamente" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting gasto:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al eliminar el gasto" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/app/api/gastos/route.ts
Normal file
102
src/app/api/gastos/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { gastoSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const obraId = searchParams.get("obraId");
|
||||||
|
const estado = searchParams.get("estado");
|
||||||
|
const categoria = searchParams.get("categoria");
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
obra: { empresaId: session.user.empresaId },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obraId) where.obraId = obraId;
|
||||||
|
if (estado) where.estado = estado;
|
||||||
|
if (categoria) where.categoria = categoria;
|
||||||
|
|
||||||
|
const gastos = await prisma.gasto.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
obra: { select: { id: true, nombre: true } },
|
||||||
|
partida: { select: { id: true, codigo: true, descripcion: true } },
|
||||||
|
creadoPor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
aprobadoPor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
},
|
||||||
|
orderBy: { fecha: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(gastos);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching gastos:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener los gastos" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId || !session.user.id) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = gastoSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify the obra belongs to the user's empresa
|
||||||
|
const obra = await prisma.obra.findFirst({
|
||||||
|
where: {
|
||||||
|
id: validatedData.obraId,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!obra) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Obra no encontrada" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gasto = await prisma.gasto.create({
|
||||||
|
data: {
|
||||||
|
concepto: validatedData.concepto,
|
||||||
|
descripcion: validatedData.descripcion,
|
||||||
|
monto: validatedData.monto,
|
||||||
|
fecha: new Date(validatedData.fecha),
|
||||||
|
categoria: validatedData.categoria,
|
||||||
|
notas: validatedData.notas,
|
||||||
|
obraId: validatedData.obraId,
|
||||||
|
partidaId: validatedData.partidaId || null,
|
||||||
|
creadoPorId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update obra gastoTotal
|
||||||
|
await prisma.obra.update({
|
||||||
|
where: { id: validatedData.obraId },
|
||||||
|
data: {
|
||||||
|
gastoTotal: { increment: validatedData.monto },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(gasto, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating gasto:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al crear el gasto" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/app/api/materiales/[id]/route.ts
Normal file
140
src/app/api/materiales/[id]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { materialSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const material = await prisma.material.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
movimientos: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
include: {
|
||||||
|
obra: { select: { nombre: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!material) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Material no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(material);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching material:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener el material" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = materialSchema.parse(body);
|
||||||
|
|
||||||
|
const existingMaterial = await prisma.material.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingMaterial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Material no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = await prisma.material.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
codigo: validatedData.codigo,
|
||||||
|
nombre: validatedData.nombre,
|
||||||
|
descripcion: validatedData.descripcion,
|
||||||
|
unidad: validatedData.unidad,
|
||||||
|
precioUnitario: validatedData.precioUnitario,
|
||||||
|
stockMinimo: validatedData.stockMinimo,
|
||||||
|
ubicacion: validatedData.ubicacion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(material);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating material:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al actualizar el material" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existingMaterial = await prisma.material.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingMaterial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Material no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.material.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Material eliminado exitosamente" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting material:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al eliminar el material" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/materiales/movimiento/route.ts
Normal file
90
src/app/api/materiales/movimiento/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const movimientoSchema = z.object({
|
||||||
|
materialId: z.string(),
|
||||||
|
tipo: z.enum(["ENTRADA", "SALIDA", "AJUSTE"]),
|
||||||
|
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
|
||||||
|
motivo: z.string().optional(),
|
||||||
|
obraId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = movimientoSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify material belongs to user's empresa
|
||||||
|
const material = await prisma.material.findFirst({
|
||||||
|
where: {
|
||||||
|
id: validatedData.materialId,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!material) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Material no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new stock
|
||||||
|
let newStock = material.stockActual;
|
||||||
|
if (validatedData.tipo === "ENTRADA") {
|
||||||
|
newStock += validatedData.cantidad;
|
||||||
|
} else if (validatedData.tipo === "SALIDA") {
|
||||||
|
newStock -= validatedData.cantidad;
|
||||||
|
if (newStock < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stock insuficiente para esta salida" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// AJUSTE - set directly
|
||||||
|
newStock = validatedData.cantidad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create movement and update stock in transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const movimiento = await tx.movimientoInventario.create({
|
||||||
|
data: {
|
||||||
|
tipo: validatedData.tipo,
|
||||||
|
cantidad: validatedData.cantidad,
|
||||||
|
motivo: validatedData.motivo,
|
||||||
|
materialId: validatedData.materialId,
|
||||||
|
obraId: validatedData.obraId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.material.update({
|
||||||
|
where: { id: validatedData.materialId },
|
||||||
|
data: { stockActual: newStock },
|
||||||
|
});
|
||||||
|
|
||||||
|
return movimiento;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating movimiento:", error);
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al registrar el movimiento" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/materiales/route.ts
Normal file
74
src/app/api/materiales/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { materialSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const materiales = await prisma.material.findMany({
|
||||||
|
where: { empresaId: session.user.empresaId },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(materiales);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching materiales:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener los materiales" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = materialSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if material code already exists
|
||||||
|
const existing = await prisma.material.findFirst({
|
||||||
|
where: {
|
||||||
|
codigo: validatedData.codigo,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ya existe un material con ese codigo" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = await prisma.material.create({
|
||||||
|
data: {
|
||||||
|
codigo: validatedData.codigo,
|
||||||
|
nombre: validatedData.nombre,
|
||||||
|
descripcion: validatedData.descripcion,
|
||||||
|
unidad: validatedData.unidad,
|
||||||
|
precioUnitario: validatedData.precioUnitario,
|
||||||
|
stockMinimo: validatedData.stockMinimo,
|
||||||
|
ubicacion: validatedData.ubicacion,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(material, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating material:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al crear el material" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/app/api/obras/[id]/route.ts
Normal file
173
src/app/api/obras/[id]/route.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { obraSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const obra = await prisma.obra.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
cliente: true,
|
||||||
|
supervisor: {
|
||||||
|
select: { id: true, nombre: true, apellido: true, email: true },
|
||||||
|
},
|
||||||
|
fases: {
|
||||||
|
include: {
|
||||||
|
tareas: {
|
||||||
|
include: {
|
||||||
|
asignado: {
|
||||||
|
select: { id: true, nombre: true, apellido: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
},
|
||||||
|
presupuestos: {
|
||||||
|
include: {
|
||||||
|
partidas: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gastos: {
|
||||||
|
orderBy: { fecha: "desc" },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
registrosAvance: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
registradoPor: {
|
||||||
|
select: { nombre: true, apellido: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!obra) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Obra no encontrada" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(obra);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching obra:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener la obra" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = obraSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify the obra belongs to the user's empresa
|
||||||
|
const existingObra = await prisma.obra.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingObra) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Obra no encontrada" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const obra = await prisma.obra.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
nombre: validatedData.nombre,
|
||||||
|
descripcion: validatedData.descripcion,
|
||||||
|
direccion: validatedData.direccion,
|
||||||
|
fechaInicio: validatedData.fechaInicio
|
||||||
|
? new Date(validatedData.fechaInicio)
|
||||||
|
: null,
|
||||||
|
fechaFinPrevista: validatedData.fechaFinPrevista
|
||||||
|
? new Date(validatedData.fechaFinPrevista)
|
||||||
|
: null,
|
||||||
|
clienteId: validatedData.clienteId || null,
|
||||||
|
supervisorId: validatedData.supervisorId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(obra);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating obra:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al actualizar la obra" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify the obra belongs to the user's empresa
|
||||||
|
const existingObra = await prisma.obra.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingObra) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Obra no encontrada" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.obra.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Obra eliminada exitosamente" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting obra:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al eliminar la obra" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/obras/route.ts
Normal file
74
src/app/api/obras/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { obraSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const obras = await prisma.obra.findMany({
|
||||||
|
where: { empresaId: session.user.empresaId },
|
||||||
|
include: {
|
||||||
|
cliente: { select: { id: true, nombre: true } },
|
||||||
|
supervisor: { select: { id: true, nombre: true, apellido: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
fases: true,
|
||||||
|
gastos: true,
|
||||||
|
presupuestos: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(obras);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching obras:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener las obras" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.empresaId) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = obraSchema.parse(body);
|
||||||
|
|
||||||
|
const obra = await prisma.obra.create({
|
||||||
|
data: {
|
||||||
|
nombre: validatedData.nombre,
|
||||||
|
descripcion: validatedData.descripcion,
|
||||||
|
direccion: validatedData.direccion,
|
||||||
|
fechaInicio: validatedData.fechaInicio
|
||||||
|
? new Date(validatedData.fechaInicio)
|
||||||
|
: null,
|
||||||
|
fechaFinPrevista: validatedData.fechaFinPrevista
|
||||||
|
? new Date(validatedData.fechaFinPrevista)
|
||||||
|
: null,
|
||||||
|
clienteId: validatedData.clienteId || null,
|
||||||
|
supervisorId: validatedData.supervisorId || null,
|
||||||
|
empresaId: session.user.empresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(obra, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating obra:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al crear la obra" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/globals.css
Normal file
69
src/app/globals.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/layout.tsx
Normal file
29
src/app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { AuthProvider } from "@/components/providers/auth-provider";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sistema de Gestion de Obras",
|
||||||
|
description: "Aplicacion para la gestion integral de obras de construccion",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="es" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/page.tsx
Normal file
12
src/app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
} else {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/components/forms/obra-form.tsx
Normal file
279
src/components/forms/obra-form.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
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 {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { obraSchema, type ObraInput } from "@/lib/validations";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { EstadoObra } from "@prisma/client";
|
||||||
|
import { ESTADO_OBRA_LABELS } from "@/types";
|
||||||
|
|
||||||
|
interface ObraFormProps {
|
||||||
|
obra?: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
direccion: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
clienteId: string | null;
|
||||||
|
supervisorId: string | null;
|
||||||
|
};
|
||||||
|
clientes?: { id: string; nombre: string }[];
|
||||||
|
supervisores?: { id: string; nombre: string; apellido: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObraForm({
|
||||||
|
obra,
|
||||||
|
clientes = [],
|
||||||
|
supervisores = [],
|
||||||
|
}: ObraFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const isEditing = !!obra;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm<ObraInput>({
|
||||||
|
resolver: zodResolver(obraSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nombre: obra?.nombre || "",
|
||||||
|
descripcion: obra?.descripcion || "",
|
||||||
|
direccion: obra?.direccion || "",
|
||||||
|
fechaInicio: obra?.fechaInicio
|
||||||
|
? new Date(obra.fechaInicio).toISOString().split("T")[0]
|
||||||
|
: "",
|
||||||
|
fechaFinPrevista: obra?.fechaFinPrevista
|
||||||
|
? new Date(obra.fechaFinPrevista).toISOString().split("T")[0]
|
||||||
|
: "",
|
||||||
|
clienteId: obra?.clienteId || "",
|
||||||
|
supervisorId: obra?.supervisorId || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: ObraInput) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const url = isEditing ? `/api/obras/${obra.id}` : "/api/obras";
|
||||||
|
const method = isEditing ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Error al guardar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: isEditing ? "Obra actualizada" : "Obra creada",
|
||||||
|
description: isEditing
|
||||||
|
? "Los cambios han sido guardados"
|
||||||
|
: "La obra ha sido creada exitosamente",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/obras/${result.id}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo guardar la obra",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informacion General</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Datos basicos del proyecto de construccion
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nombre">Nombre de la Obra *</Label>
|
||||||
|
<Input
|
||||||
|
id="nombre"
|
||||||
|
placeholder="Ej: Torre Residencial Norte"
|
||||||
|
{...register("nombre")}
|
||||||
|
/>
|
||||||
|
{errors.nombre && (
|
||||||
|
<p className="text-sm text-red-600">{errors.nombre.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="descripcion">Descripcion</Label>
|
||||||
|
<Textarea
|
||||||
|
id="descripcion"
|
||||||
|
placeholder="Descripcion del proyecto..."
|
||||||
|
{...register("descripcion")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="direccion">Direccion *</Label>
|
||||||
|
<Input
|
||||||
|
id="direccion"
|
||||||
|
placeholder="Ej: Av. Principal #123, Ciudad"
|
||||||
|
{...register("direccion")}
|
||||||
|
/>
|
||||||
|
{errors.direccion && (
|
||||||
|
<p className="text-sm text-red-600">{errors.direccion.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fechas</CardTitle>
|
||||||
|
<CardDescription>Cronograma del proyecto</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fechaInicio">Fecha de Inicio</Label>
|
||||||
|
<Input id="fechaInicio" type="date" {...register("fechaInicio")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fechaFinPrevista">Fecha Fin Prevista</Label>
|
||||||
|
<Input
|
||||||
|
id="fechaFinPrevista"
|
||||||
|
type="date"
|
||||||
|
{...register("fechaFinPrevista")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Asignaciones</CardTitle>
|
||||||
|
<CardDescription>Cliente y supervisor del proyecto</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cliente</Label>
|
||||||
|
<Select
|
||||||
|
value={watch("clienteId") || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setValue("clienteId", value === "none" ? "" : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar cliente" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sin cliente asignado</SelectItem>
|
||||||
|
{clientes.map((cliente) => (
|
||||||
|
<SelectItem key={cliente.id} value={cliente.id}>
|
||||||
|
{cliente.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Supervisor</Label>
|
||||||
|
<Select
|
||||||
|
value={watch("supervisorId") || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setValue("supervisorId", value === "none" ? "" : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar supervisor" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sin supervisor asignado</SelectItem>
|
||||||
|
{supervisores.map((sup) => (
|
||||||
|
<SelectItem key={sup.id} value={sup.id}>
|
||||||
|
{sup.nombre} {sup.apellido}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Estado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
defaultValue={obra.estado}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// This would need additional handling for estado changes
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(ESTADO_OBRA_LABELS).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isEditing ? "Guardar Cambios" : "Crear Obra"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/layout/header.tsx
Normal file
87
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Bell, LogOut, User, Settings } from "lucide-react";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { ROLES_LABELS } from "@/types";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
signOut({ callbackUrl: "/login" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-900">
|
||||||
|
Sistema de Gestion de Obras
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback className="bg-blue-600 text-white">
|
||||||
|
{session?.user
|
||||||
|
? getInitials(session.user.nombre, session.user.apellido)
|
||||||
|
: "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="hidden text-left md:block">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{session?.user?.nombre} {session?.user?.apellido}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{session?.user?.role
|
||||||
|
? ROLES_LABELS[session.user.role]
|
||||||
|
: "Usuario"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Mi Cuenta</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Perfil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Configuracion
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Cerrar Sesion
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/layout/sidebar.tsx
Normal file
127
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
LayoutDashboard,
|
||||||
|
HardHat,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
FileBarChart,
|
||||||
|
Package,
|
||||||
|
Settings,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
name: "Dashboard",
|
||||||
|
href: "/dashboard",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Obras",
|
||||||
|
href: "/obras",
|
||||||
|
icon: HardHat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Finanzas",
|
||||||
|
href: "/finanzas",
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Recursos",
|
||||||
|
href: "/recursos",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Inventario",
|
||||||
|
href: "/recursos/materiales",
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reportes",
|
||||||
|
href: "/reportes",
|
||||||
|
icon: FileBarChart,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col bg-slate-900 text-white transition-all duration-300",
|
||||||
|
collapsed ? "w-16" : "w-64"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-center border-b border-slate-700 px-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-8 w-8 text-blue-400" />
|
||||||
|
<span className="text-lg font-bold">ConstruApp</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{collapsed && (
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<Building2 className="h-8 w-8 text-blue-400" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1 px-2 py-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 p-2">
|
||||||
|
<Link
|
||||||
|
href="/configuracion"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>Configuracion</span>}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-3 top-20 h-6 w-6 rounded-full border border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/providers/auth-provider.tsx
Normal file
12
src/components/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
180
src/components/tables/data-table.tsx
Normal file
180
src/components/tables/data-table.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: keyof T | string;
|
||||||
|
header: string;
|
||||||
|
render?: (item: T) => React.ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
searchKey?: keyof T;
|
||||||
|
pageSize?: number;
|
||||||
|
actions?: (item: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T extends { id: string }>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
searchPlaceholder = "Buscar...",
|
||||||
|
searchKey,
|
||||||
|
pageSize = 10,
|
||||||
|
actions,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!search || !searchKey) return data;
|
||||||
|
return data.filter((item) => {
|
||||||
|
const value = item[searchKey];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.toLowerCase().includes(search.toLowerCase());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [data, search, searchKey]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||||||
|
|
||||||
|
const getValue = (item: T, key: string): unknown => {
|
||||||
|
const keys = key.split(".");
|
||||||
|
let value: unknown = item;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === "object" && k in value) {
|
||||||
|
value = (value as Record<string, unknown>)[k];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{searchKey && (
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead key={String(column.key)}>{column.header}</TableHead>
|
||||||
|
))}
|
||||||
|
{actions && <TableHead className="w-[100px]">Acciones</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length + (actions ? 1 : 0)}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No se encontraron resultados.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={String(column.key)}>
|
||||||
|
{column.render
|
||||||
|
? column.render(item)
|
||||||
|
: String(getValue(item, String(column.key)) ?? "")}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{actions && <TableCell>{actions(item)}</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mostrando {startIndex + 1} -{" "}
|
||||||
|
{Math.min(startIndex + pageSize, filteredData.length)} de{" "}
|
||||||
|
{filteredData.length} registros
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
Pagina {currentPage} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/ui/alert-dialog.tsx
Normal file
140
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
49
src/components/ui/avatar.tsx
Normal file
49
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
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",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
121
src/components/ui/dialog.tsx
Normal file
121
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
199
src/components/ui/dropdown-menu.tsx
Normal file
199
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
25
src/components/ui/label.tsx
Normal file
25
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
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<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
38
src/components/ui/progress.tsx
Normal file
38
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
|
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||||
|
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={max}
|
||||||
|
className={cn(
|
||||||
|
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Progress.displayName = "Progress";
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
159
src/components/ui/select.tsx
Normal file
159
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ 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<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ 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,
|
||||||
|
};
|
||||||
30
src/components/ui/separator.tsx
Normal file
30
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ 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;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
128
src/components/ui/toast.tsx
Normal file
128
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/hooks/use-toast.ts
Normal file
187
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
111
src/lib/auth.ts
Normal file
111
src/lib/auth.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
apellido: string;
|
||||||
|
role: Role;
|
||||||
|
empresaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
apellido: string;
|
||||||
|
role: Role;
|
||||||
|
empresaId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
empresaId: string;
|
||||||
|
nombre: string;
|
||||||
|
apellido: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email as string },
|
||||||
|
include: { empresa: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.activo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch = await bcrypt.compare(
|
||||||
|
credentials.password as string,
|
||||||
|
user.password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nombre: user.nombre,
|
||||||
|
apellido: user.apellido,
|
||||||
|
role: user.role,
|
||||||
|
empresaId: user.empresaId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.role = user.role;
|
||||||
|
token.empresaId = user.empresaId;
|
||||||
|
token.nombre = user.nombre;
|
||||||
|
token.apellido = user.apellido;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.role = token.role;
|
||||||
|
session.user.empresaId = token.empresaId;
|
||||||
|
session.user.nombre = token.nombre;
|
||||||
|
session.user.apellido = token.apellido;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
error: "/login",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
|
},
|
||||||
|
});
|
||||||
11
src/lib/prisma.ts
Normal file
11
src/lib/prisma.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
49
src/lib/utils.ts
Normal file
49
src/lib/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat("es-MX", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "MXN",
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
return new Intl.DateTimeFormat("es-MX", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateShort(date: Date | string): string {
|
||||||
|
return new Intl.DateTimeFormat("es-MX", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercentage(value: number): string {
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePercentage(value: number, total: number): number {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return (value / total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitials(nombre: string, apellido?: string): string {
|
||||||
|
const first = nombre?.charAt(0).toUpperCase() || "";
|
||||||
|
const last = apellido?.charAt(0).toUpperCase() || "";
|
||||||
|
return `${first}${last}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return `${text.substring(0, maxLength)}...`;
|
||||||
|
}
|
||||||
207
src/lib/validations.ts
Normal file
207
src/lib/validations.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Auth validations
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email("Email invalido"),
|
||||||
|
password: z.string().min(6, "La contrasena debe tener al menos 6 caracteres"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email("Email invalido"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, "La contrasena debe tener al menos 6 caracteres"),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
|
||||||
|
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
|
||||||
|
empresaNombre: z
|
||||||
|
.string()
|
||||||
|
.min(2, "El nombre de empresa debe tener al menos 2 caracteres"),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Las contrasenas no coinciden",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obra validations
|
||||||
|
export const obraSchema = z.object({
|
||||||
|
nombre: z
|
||||||
|
.string()
|
||||||
|
.min(3, "El nombre debe tener al menos 3 caracteres")
|
||||||
|
.max(100, "El nombre no puede exceder 100 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
direccion: z
|
||||||
|
.string()
|
||||||
|
.min(5, "La direccion debe tener al menos 5 caracteres"),
|
||||||
|
fechaInicio: z.string().optional(),
|
||||||
|
fechaFinPrevista: z.string().optional(),
|
||||||
|
clienteId: z.string().optional(),
|
||||||
|
supervisorId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gasto validations
|
||||||
|
export const gastoSchema = z.object({
|
||||||
|
concepto: z
|
||||||
|
.string()
|
||||||
|
.min(3, "El concepto debe tener al menos 3 caracteres")
|
||||||
|
.max(200, "El concepto no puede exceder 200 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
monto: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
|
fecha: z.string(),
|
||||||
|
categoria: z.enum([
|
||||||
|
"MATERIALES",
|
||||||
|
"MANO_DE_OBRA",
|
||||||
|
"EQUIPOS",
|
||||||
|
"SUBCONTRATISTAS",
|
||||||
|
"PERMISOS",
|
||||||
|
"TRANSPORTE",
|
||||||
|
"SERVICIOS",
|
||||||
|
"OTROS",
|
||||||
|
]),
|
||||||
|
obraId: z.string(),
|
||||||
|
partidaId: z.string().optional(),
|
||||||
|
notas: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Presupuesto validations
|
||||||
|
export const presupuestoSchema = z.object({
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
obraId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const partidaPresupuestoSchema = z.object({
|
||||||
|
codigo: z.string().min(1, "El codigo es requerido"),
|
||||||
|
descripcion: z.string().min(3, "La descripcion es requerida"),
|
||||||
|
unidad: z.enum([
|
||||||
|
"UNIDAD",
|
||||||
|
"METRO",
|
||||||
|
"METRO_CUADRADO",
|
||||||
|
"METRO_CUBICO",
|
||||||
|
"KILOGRAMO",
|
||||||
|
"TONELADA",
|
||||||
|
"LITRO",
|
||||||
|
"BOLSA",
|
||||||
|
"PIEZA",
|
||||||
|
"ROLLO",
|
||||||
|
"CAJA",
|
||||||
|
]),
|
||||||
|
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
|
||||||
|
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
|
||||||
|
categoria: z.enum([
|
||||||
|
"MATERIALES",
|
||||||
|
"MANO_DE_OBRA",
|
||||||
|
"EQUIPOS",
|
||||||
|
"SUBCONTRATISTAS",
|
||||||
|
"PERMISOS",
|
||||||
|
"TRANSPORTE",
|
||||||
|
"SERVICIOS",
|
||||||
|
"OTROS",
|
||||||
|
]),
|
||||||
|
presupuestoId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Material validations
|
||||||
|
export const materialSchema = z.object({
|
||||||
|
codigo: z.string().min(1, "El codigo es requerido"),
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
unidad: z.enum([
|
||||||
|
"UNIDAD",
|
||||||
|
"METRO",
|
||||||
|
"METRO_CUADRADO",
|
||||||
|
"METRO_CUBICO",
|
||||||
|
"KILOGRAMO",
|
||||||
|
"TONELADA",
|
||||||
|
"LITRO",
|
||||||
|
"BOLSA",
|
||||||
|
"PIEZA",
|
||||||
|
"ROLLO",
|
||||||
|
"CAJA",
|
||||||
|
]),
|
||||||
|
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
|
||||||
|
stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"),
|
||||||
|
ubicacion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empleado validations
|
||||||
|
export const empleadoSchema = z.object({
|
||||||
|
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
|
||||||
|
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
|
||||||
|
documento: z.string().optional(),
|
||||||
|
telefono: z.string().optional(),
|
||||||
|
email: z.string().email("Email invalido").optional().or(z.literal("")),
|
||||||
|
puesto: z.string().min(2, "El puesto es requerido"),
|
||||||
|
salarioBase: z.number().optional(),
|
||||||
|
fechaIngreso: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subcontratista validations
|
||||||
|
export const subcontratistaSchema = z.object({
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
rfc: z.string().optional(),
|
||||||
|
especialidad: z.string().min(3, "La especialidad es requerida"),
|
||||||
|
telefono: z.string().optional(),
|
||||||
|
email: z.string().email("Email invalido").optional().or(z.literal("")),
|
||||||
|
direccion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factura validations
|
||||||
|
export const facturaSchema = z.object({
|
||||||
|
numero: z.string().min(1, "El numero de factura es requerido"),
|
||||||
|
tipo: z.enum(["EMITIDA", "RECIBIDA"]),
|
||||||
|
concepto: z.string().min(3, "El concepto es requerido"),
|
||||||
|
monto: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
|
iva: z.number().min(0, "El IVA no puede ser negativo"),
|
||||||
|
fechaEmision: z.string(),
|
||||||
|
fechaVencimiento: z.string().optional(),
|
||||||
|
obraId: z.string(),
|
||||||
|
proveedorNombre: z.string().optional(),
|
||||||
|
proveedorRfc: z.string().optional(),
|
||||||
|
clienteNombre: z.string().optional(),
|
||||||
|
clienteRfc: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cliente validations
|
||||||
|
export const clienteSchema = z.object({
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
rfc: z.string().optional(),
|
||||||
|
direccion: z.string().optional(),
|
||||||
|
telefono: z.string().optional(),
|
||||||
|
email: z.string().email("Email invalido").optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fase validations
|
||||||
|
export const faseSchema = z.object({
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
fechaInicio: z.string().optional(),
|
||||||
|
fechaFin: z.string().optional(),
|
||||||
|
obraId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tarea validations
|
||||||
|
export const tareaSchema = z.object({
|
||||||
|
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
prioridad: z.number().min(1).max(5),
|
||||||
|
fechaInicio: z.string().optional(),
|
||||||
|
fechaFin: z.string().optional(),
|
||||||
|
faseId: z.string(),
|
||||||
|
asignadoId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
export type ObraInput = z.infer<typeof obraSchema>;
|
||||||
|
export type GastoInput = z.infer<typeof gastoSchema>;
|
||||||
|
export type PresupuestoInput = z.infer<typeof presupuestoSchema>;
|
||||||
|
export type PartidaPresupuestoInput = z.infer<typeof partidaPresupuestoSchema>;
|
||||||
|
export type MaterialInput = z.infer<typeof materialSchema>;
|
||||||
|
export type EmpleadoInput = z.infer<typeof empleadoSchema>;
|
||||||
|
export type SubcontratistaInput = z.infer<typeof subcontratistaSchema>;
|
||||||
|
export type FacturaInput = z.infer<typeof facturaSchema>;
|
||||||
|
export type ClienteInput = z.infer<typeof clienteSchema>;
|
||||||
|
export type FaseInput = z.infer<typeof faseSchema>;
|
||||||
|
export type TareaInput = z.infer<typeof tareaSchema>;
|
||||||
36
src/middleware.ts
Normal file
36
src/middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const { nextUrl } = req;
|
||||||
|
const isLoggedIn = !!req.auth;
|
||||||
|
|
||||||
|
const isAuthPage =
|
||||||
|
nextUrl.pathname.startsWith("/login") ||
|
||||||
|
nextUrl.pathname.startsWith("/registro");
|
||||||
|
|
||||||
|
const isPublicPage = nextUrl.pathname === "/";
|
||||||
|
|
||||||
|
const isApiRoute = nextUrl.pathname.startsWith("/api");
|
||||||
|
|
||||||
|
// Allow API routes to pass through
|
||||||
|
if (isApiRoute) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect logged-in users away from auth pages
|
||||||
|
if (isAuthPage && isLoggedIn) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect non-logged-in users to login page
|
||||||
|
if (!isAuthPage && !isPublicPage && !isLoggedIn) {
|
||||||
|
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
|
||||||
|
};
|
||||||
151
src/types/index.ts
Normal file
151
src/types/index.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
Role,
|
||||||
|
EstadoObra,
|
||||||
|
EstadoTarea,
|
||||||
|
EstadoGasto,
|
||||||
|
CategoriaGasto,
|
||||||
|
TipoFactura,
|
||||||
|
EstadoFactura,
|
||||||
|
TipoMovimiento,
|
||||||
|
UnidadMedida,
|
||||||
|
} from "@prisma/client";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Role,
|
||||||
|
EstadoObra,
|
||||||
|
EstadoTarea,
|
||||||
|
EstadoGasto,
|
||||||
|
CategoriaGasto,
|
||||||
|
TipoFactura,
|
||||||
|
EstadoFactura,
|
||||||
|
TipoMovimiento,
|
||||||
|
UnidadMedida,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
obrasActivas: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
avancePromedio: number;
|
||||||
|
obrasCompletadas: number;
|
||||||
|
gastoPendiente: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObraResumen {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
estado: EstadoObra;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
presupuestoTotal: number;
|
||||||
|
gastoTotal: number;
|
||||||
|
fechaInicio: Date | null;
|
||||||
|
fechaFinPrevista: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GastoMensual {
|
||||||
|
mes: string;
|
||||||
|
gastos: number;
|
||||||
|
presupuesto: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GastoPorCategoria {
|
||||||
|
categoria: CategoriaGasto;
|
||||||
|
total: number;
|
||||||
|
porcentaje: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertaInventario {
|
||||||
|
materialId: string;
|
||||||
|
nombre: string;
|
||||||
|
stockActual: number;
|
||||||
|
stockMinimo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLES_LABELS: Record<Role, string> = {
|
||||||
|
ADMIN: "Administrador",
|
||||||
|
GERENTE: "Gerente",
|
||||||
|
SUPERVISOR: "Supervisor",
|
||||||
|
CONTADOR: "Contador",
|
||||||
|
EMPLEADO: "Empleado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_OBRA_LABELS: Record<EstadoObra, string> = {
|
||||||
|
PLANIFICACION: "Planificacion",
|
||||||
|
EN_PROGRESO: "En Progreso",
|
||||||
|
PAUSADA: "Pausada",
|
||||||
|
COMPLETADA: "Completada",
|
||||||
|
CANCELADA: "Cancelada",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_OBRA_COLORS: Record<EstadoObra, string> = {
|
||||||
|
PLANIFICACION: "bg-blue-100 text-blue-800",
|
||||||
|
EN_PROGRESO: "bg-green-100 text-green-800",
|
||||||
|
PAUSADA: "bg-yellow-100 text-yellow-800",
|
||||||
|
COMPLETADA: "bg-gray-100 text-gray-800",
|
||||||
|
CANCELADA: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_TAREA_LABELS: Record<EstadoTarea, string> = {
|
||||||
|
PENDIENTE: "Pendiente",
|
||||||
|
EN_PROGRESO: "En Progreso",
|
||||||
|
COMPLETADA: "Completada",
|
||||||
|
BLOQUEADA: "Bloqueada",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_TAREA_COLORS: Record<EstadoTarea, string> = {
|
||||||
|
PENDIENTE: "bg-gray-100 text-gray-800",
|
||||||
|
EN_PROGRESO: "bg-blue-100 text-blue-800",
|
||||||
|
COMPLETADA: "bg-green-100 text-green-800",
|
||||||
|
BLOQUEADA: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_GASTO_LABELS: Record<EstadoGasto, string> = {
|
||||||
|
PENDIENTE: "Pendiente",
|
||||||
|
APROBADO: "Aprobado",
|
||||||
|
RECHAZADO: "Rechazado",
|
||||||
|
PAGADO: "Pagado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_GASTO_COLORS: Record<EstadoGasto, string> = {
|
||||||
|
PENDIENTE: "bg-yellow-100 text-yellow-800",
|
||||||
|
APROBADO: "bg-green-100 text-green-800",
|
||||||
|
RECHAZADO: "bg-red-100 text-red-800",
|
||||||
|
PAGADO: "bg-blue-100 text-blue-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORIA_GASTO_LABELS: Record<CategoriaGasto, string> = {
|
||||||
|
MATERIALES: "Materiales",
|
||||||
|
MANO_DE_OBRA: "Mano de Obra",
|
||||||
|
EQUIPOS: "Equipos",
|
||||||
|
SUBCONTRATISTAS: "Subcontratistas",
|
||||||
|
PERMISOS: "Permisos",
|
||||||
|
TRANSPORTE: "Transporte",
|
||||||
|
SERVICIOS: "Servicios",
|
||||||
|
OTROS: "Otros",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNIDAD_MEDIDA_LABELS: Record<UnidadMedida, string> = {
|
||||||
|
UNIDAD: "Unidad",
|
||||||
|
METRO: "Metro",
|
||||||
|
METRO_CUADRADO: "m2",
|
||||||
|
METRO_CUBICO: "m3",
|
||||||
|
KILOGRAMO: "Kg",
|
||||||
|
TONELADA: "Ton",
|
||||||
|
LITRO: "Lt",
|
||||||
|
BOLSA: "Bolsa",
|
||||||
|
PIEZA: "Pieza",
|
||||||
|
ROLLO: "Rollo",
|
||||||
|
CAJA: "Caja",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ESTADO_FACTURA_LABELS: Record<EstadoFactura, string> = {
|
||||||
|
PENDIENTE: "Pendiente",
|
||||||
|
PAGADA: "Pagada",
|
||||||
|
VENCIDA: "Vencida",
|
||||||
|
CANCELADA: "Cancelada",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIPO_FACTURA_LABELS: Record<TipoFactura, string> = {
|
||||||
|
EMITIDA: "Emitida",
|
||||||
|
RECIBIDA: "Recibida",
|
||||||
|
};
|
||||||
64
tailwind.config.ts
Normal file
64
tailwind.config.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
"1": "hsl(var(--chart-1))",
|
||||||
|
"2": "hsl(var(--chart-2))",
|
||||||
|
"3": "hsl(var(--chart-3))",
|
||||||
|
"4": "hsl(var(--chart-4))",
|
||||||
|
"5": "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user