first version
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
FROM oven/bun:latest
|
||||
|
||||
ENV TZ="Europe/Madrid"
|
||||
|
||||
# Install additional OS packages.
|
||||
RUN apt-get update && apt-get install -y curl git
|
||||
|
||||
# Use node user
|
||||
USER bun
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: postgres:18
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
env_file:
|
||||
- ../.env
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
TZ: Europe/Madrid
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
|
||||
{
|
||||
"name": "bun",
|
||||
"dockerComposeFile": "compose.yml",
|
||||
"service": "app",
|
||||
"shutdownAction": "stopCompose",
|
||||
"workspaceFolder": "/workspace",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
5432
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "bun i",
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "bun run dev",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"oven.bun-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"usernamehw.errorlens",
|
||||
"Prisma.prisma",
|
||||
"esbenp.prettier-vscode",
|
||||
"dsznajder.es7-react-js-snippets",
|
||||
"csstools.postcss"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "bun",
|
||||
"containerEnv": {
|
||||
"SHELL": "/bin/bash"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.devcontainer
|
||||
.vscode
|
||||
.next/
|
||||
|
||||
Dockerfile
|
||||
compose.yml
|
||||
node_modules
|
||||
README.md
|
||||
*.csv
|
||||
@@ -0,0 +1,14 @@
|
||||
# DATABASE
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
|
||||
# PRISMA
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public"
|
||||
|
||||
# FRONTEND
|
||||
DOMAIN=localhost
|
||||
AUTH_TRUST_HOST="http://localhost"
|
||||
AUTH_SECRET=your_secret_key_here
|
||||
Binary file not shown.
+41
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.husky
|
||||
coverage
|
||||
.prettierignore
|
||||
.stylelintignore
|
||||
.eslintignore
|
||||
stories
|
||||
storybook-static
|
||||
*.log
|
||||
playwright-report
|
||||
.nyc_output
|
||||
test-results
|
||||
junit.xml
|
||||
docs
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"trailingComma": "all",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"endOfLine": "auto",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "always"
|
||||
},
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.format.enable": true,
|
||||
"eslint.run": "onSave",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
FROM oven/bun:latest AS base
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
COPY src/prisma ./src/prisma
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN bun run build
|
||||
|
||||
FROM builder AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ENV TZ="Europe/Madrid"
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
COPY --from=builder /app/.next/server ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -0,0 +1,236 @@
|
||||
# Stock Manager Home
|
||||
|
||||
Sistema de gestión de inventario y asignación de activos desarrollado con Next.js 15, Prisma, PostgreSQL y NextAuth.
|
||||
|
||||
## 📋 Descripción
|
||||
|
||||
Stock Manager es una aplicación web completa para la gestión de inventarios, activos y asignaciones de equipamiento. Permite controlar tanto ítems genéricos (gestionados por cantidad) como activos serializados (con número de serie único), registrar movimientos de stock, y gestionar asignaciones a destinatarios por departamentos.
|
||||
|
||||
## ✨ Características principales
|
||||
|
||||
### Gestión de Inventario
|
||||
|
||||
- **Ítems genéricos**: Productos sin número de serie, gestionados por cantidad en stock
|
||||
- **Activos serializados**: Equipos individuales con número de serie único
|
||||
- **Categorías**: Organización jerárquica de productos
|
||||
- **Control de stock**: Niveles mínimos/máximos, alertas de stock bajo (WIP)
|
||||
- **Estados múltiples**: Disponible, Asignado, Reservado, En reparación, Averiado, Robado, Dado de baja
|
||||
|
||||
### Gestión de Destinatarios
|
||||
|
||||
- Registro de empleados/destinatarios por departamento
|
||||
- Información de contacto (email, teléfono)
|
||||
- Historial de asignaciones por destinatario
|
||||
|
||||
### Sistema de Asignaciones
|
||||
|
||||
- Asignación de ítems genéricos (por cantidad)
|
||||
- Asignación de activos individuales (uno a uno)
|
||||
- Seguimiento de fechas de asignación y devolución
|
||||
- Notas y detalles de cada asignación
|
||||
- Registro del usuario que realiza cada asignación
|
||||
|
||||
### Movimientos e Historial
|
||||
|
||||
- Registro completo de todos los movimientos de stock
|
||||
- Tipos de movimiento: IN, OUT, ASSIGNMENT, RETURN, ADJUSTMENT, DELETED
|
||||
- Trazabilidad completa con stock previo y nuevo
|
||||
- Auditoría de todos los cambios con usuario y fecha
|
||||
|
||||
### Importación de Datos
|
||||
|
||||
- Importación masiva vía CSV
|
||||
- Plantilla descargable para importaciones
|
||||
- Validación de datos en el proceso de importación
|
||||
|
||||
### Sistema de Autenticación y Roles
|
||||
|
||||
- Autenticación segura con NextAuth v5
|
||||
- 4 roles de usuario: ADMIN, MANAGER, STAFF, VIEWER
|
||||
- Permisos diferenciados según rol
|
||||
- Contraseñas hasheadas con bcrypt
|
||||
|
||||
## 🚀 Tecnologías
|
||||
|
||||
- **Framework**: Next.js 15 (App Router)
|
||||
- **Base de datos**: PostgreSQL 18
|
||||
- **ORM**: Prisma 6
|
||||
- **Autenticación**: NextAuth v5
|
||||
- **UI**: React 19, Tailwind CSS, Shadcn
|
||||
- **Validación**: Zod
|
||||
- **Formularios**: React Hook Form
|
||||
- **Runtime**: Bun (recomendado)
|
||||
- **Containerización**: Docker + Docker Compose
|
||||
|
||||
## 🔨 Desarrollo en entorno DevContainer
|
||||
|
||||
Este proyecto incluye configuración para desarrollo en contenedor usando [DevContainer](https://containers.dev/).
|
||||
|
||||
1. Abre el proyecto en VS Code y selecciona "Reopen in Container".
|
||||
2. El entorno instalará dependencias automáticamente (bun i) y lanzará el servidor de desarrollo (bun run dev).
|
||||
3. El puerto 3000 estará disponible para acceder a la app desde tu navegador.
|
||||
|
||||
## 🔨 Desarrollo local
|
||||
|
||||
### Prerrequisitos
|
||||
|
||||
- Node.js 18+ o Bun
|
||||
- PostgreSQL 13+ (o usar Docker Compose)
|
||||
- Git
|
||||
|
||||
1. Clonar el repositorio:
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd stock-manager
|
||||
```
|
||||
|
||||
2. Instalar dependencias:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
# o
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configurar variables de entorno:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Editar `.env` con tus configuraciones:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/stockmanager"
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=stockmanager
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# NextAuth
|
||||
AUTH_SECRET="your-secret-key-here"
|
||||
AUTH_TRUST_HOST=true
|
||||
DOMAIN=localhost:3000
|
||||
```
|
||||
|
||||
4. Ejecutar migraciones de base de datos:
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
# o generar el cliente Prisma
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
5. (Opcional) Ejecutar seed para datos iniciales:
|
||||
|
||||
```bash
|
||||
bun run db:seed
|
||||
```
|
||||
|
||||
6. Iniciar el servidor de desarrollo:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Abrir [http://localhost:3000](http://localhost:3000) en el navegador.
|
||||
|
||||
## 🐳 Despliegue con Docker
|
||||
|
||||
### Producción
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.yaml up -d
|
||||
```
|
||||
|
||||
Con Traefik (reverse proxy):
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.yaml -f compose.traefik.yaml up -d
|
||||
```
|
||||
|
||||
## 📜 Scripts disponibles
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
bun run dev # Inicia servidor de desarrollo con Turbopack
|
||||
bun run build # Construye para producción
|
||||
bun run start # Inicia servidor de producción
|
||||
|
||||
# Linting y formato
|
||||
bun run lint # Ejecuta ESLint
|
||||
bun run lint:fix # Corrige errores de ESLint automáticamente
|
||||
bun run format # Formatea código con Prettier
|
||||
|
||||
# Base de datos
|
||||
bun run db:push # Sincroniza schema sin migraciones
|
||||
bun run db:migrate # Crea y ejecuta migraciones
|
||||
bun run db:migrate:reset # Resetea BD y ejecuta migraciones
|
||||
bun run db:deploy # Ejecuta migraciones en producción
|
||||
bun run db:generate # Genera cliente Prisma
|
||||
bun run db:studio # Abre Prisma Studio (GUI para BD)
|
||||
```
|
||||
|
||||
## 📁 Estructura del proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App Router de Next.js
|
||||
│ ├── (auth)/ # Rutas de autenticación
|
||||
│ │ └── login/
|
||||
│ ├── (dashboard)/ # Rutas del dashboard
|
||||
│ │ ├── (home)/ # Página principal
|
||||
│ │ ├── assignments/ # Gestión de asignaciones
|
||||
│ │ ├── import/ # Importación de datos
|
||||
│ │ ├── inventory/ # Gestión de inventario
|
||||
│ │ │ ├── assets/ # Activos serializados
|
||||
│ │ │ ├── categories/# Categorías
|
||||
│ │ │ └── items/ # Ítems genéricos
|
||||
│ │ ├── movements/ # Historial de movimientos
|
||||
│ │ └── recipients/ # Gestión de destinatarios
|
||||
│ └── api/ # API routes
|
||||
│ ├── auth/ # Endpoints de autenticación
|
||||
│ └── db/ # Endpoints de base de datos
|
||||
├── components/ # Componentes React
|
||||
│ ├── auth/ # Componentes de autenticación
|
||||
│ ├── common/ # Componentes comunes
|
||||
│ ├── forms/ # Componentes de formularios
|
||||
│ ├── layout/ # Componentes de layout
|
||||
│ └── ui/ # Componentes UI (Radix)
|
||||
├── lib/ # Utilidades y configuración
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── schemas/ # Schemas de validación Zod
|
||||
│ └── types/ # Tipos TypeScript
|
||||
├── services/ # Servicios de lógica de negocio
|
||||
├── prisma/ # Schema y migraciones Prisma
|
||||
│ ├── schema.prisma # Definición del modelo de datos
|
||||
│ ├── migrations/ # Historial de migraciones
|
||||
│ └── seed.ts # Datos iniciales
|
||||
└── styles/ # Estilos globales
|
||||
```
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
- Autenticación mediante JWT
|
||||
- Contraseñas hasheadas con bcrypt
|
||||
- Validación de datos con Zod en cliente y servidor
|
||||
- Protección de rutas según roles
|
||||
- Variables de entorno para secretos
|
||||
- Sanitización de inputs
|
||||
|
||||
## 🗃️ Modelo de datos
|
||||
|
||||
El sistema gestiona las siguientes entidades principales:
|
||||
|
||||
- **Users**: Usuarios del sistema con roles y permisos
|
||||
- **Recipients**: Destinatarios/empleados que reciben asignaciones
|
||||
- **Categories**: Categorías de productos
|
||||
- **Items**: Ítems genéricos (sin número de serie)
|
||||
- **Assets**: Activos individuales (con número de serie)
|
||||
- **Assignments**: Asignaciones de ítems/activos a destinatarios
|
||||
- **Movements**: Registro de todos los movimientos de inventario
|
||||
|
||||
Ver `src/prisma/schema.prisma` para el esquema completo.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: traefik
|
||||
restart: always
|
||||
command:
|
||||
- --api.insecure=true
|
||||
- --providers.docker
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
ports:
|
||||
- 80:80
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- traefik-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)
|
||||
- traefik.http.routers.traefik.entrypoints=web
|
||||
- traefik.http.services.traefik.loadbalancer.server.port=8080
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
container_name: db
|
||||
restart: always
|
||||
environment:
|
||||
TZ: Europe/Madrid
|
||||
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} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
command: postgres -c listen_addresses='*'
|
||||
ports:
|
||||
- 5432
|
||||
networks:
|
||||
- traefik-network
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: app
|
||||
restart: always
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: Europe/Madrid
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
NODE_ENV: production
|
||||
DOMAIN: ${DOMAIN}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- traefik-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.app.rule=Host(`${DOMAIN}`)
|
||||
- traefik.http.routers.app.entrypoints=web
|
||||
- traefik.http.services.app.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
networks:
|
||||
traefik-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18
|
||||
container_name: db
|
||||
restart: always
|
||||
environment:
|
||||
TZ: Europe/Madrid
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
command: postgres -c listen_addresses='*'
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: app
|
||||
restart: always
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: Europe/Madrid
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
NODE_ENV: production
|
||||
DOMAIN: ${DOMAIN}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc"
|
||||
import eslintPlugin from "@eslint/js"
|
||||
import type { Linter } from "eslint"
|
||||
|
||||
const compat = new FlatCompat()
|
||||
|
||||
const eslintConfig = [
|
||||
{
|
||||
name: "custom/eslint/recommended",
|
||||
files: ["**/*.ts?(x)"],
|
||||
...eslintPlugin.configs.recommended,
|
||||
},
|
||||
]
|
||||
|
||||
const ignoresConfig = [
|
||||
{
|
||||
name: "custom/eslint/ignores",
|
||||
// the ignores option needs to be in a separate configuration object
|
||||
// replaces the .eslintignore file
|
||||
ignores: [
|
||||
".next/",
|
||||
".vscode/",
|
||||
"public/",
|
||||
"src/generated/",
|
||||
"node_modules/",
|
||||
"src/components/ui/",
|
||||
],
|
||||
},
|
||||
] as Linter.Config[]
|
||||
|
||||
export default [
|
||||
...compat.extends(
|
||||
"next/core-web-vitals",
|
||||
"next/typescript",
|
||||
"plugin:import/recommended",
|
||||
"plugin:playwright/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
),
|
||||
...compat.config({
|
||||
rules: {
|
||||
"no-unused-vars": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"simple-import-sort/imports": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
},
|
||||
plugins: ["simple-import-sort"],
|
||||
globals: { React: true, Prisma: true },
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
}),
|
||||
...eslintConfig,
|
||||
...ignoresConfig,
|
||||
] satisfies Linter.Config[]
|
||||
@@ -0,0 +1,22 @@
|
||||
serialNumber,name,category,deliveryNote,stock,assigned,username,firstName,lastName
|
||||
,HDMI Cable,Peripherals,,10,false,,,
|
||||
ABC123456,iPhone 13 Pro,Smartphones,DN789012,1,false,,,
|
||||
XYZ789012,Dell Latitude 5420,Laptops,DN345678,1,true,jsmith,John,Smith
|
||||
QWE345678,HP LaserJet Pro,Printers,DN901234,1,false,,,
|
||||
MNB567890,iPad Air,Tablets,DN567890,1,true,sjones,Sarah,Jones
|
||||
JKL901234,Logitech MX Master,Peripherals,DN123456,1,false,,,
|
||||
POI234567,ThinkPad X1 Carbon,Laptops,DN678901,1,true,mbrown,Michael,Brown
|
||||
ZXC890123,Samsung Galaxy S21,Smartphones,DN234567,1,false,,,
|
||||
VBN345678,Microsoft Surface Pro,Tablets,DN890123,1,true,ewilson,Emily,Wilson
|
||||
UYT678901,Canon ImageRunner,Printers,DN456789,1,false,,,
|
||||
HGF123456,MacBook Air M1,Laptops,DN012345,1,true,dmiller,David,Miller
|
||||
WER789012,AirPods Pro,Accessories,DN543210,1,false,,,
|
||||
LKJ234567,Lenovo ThinkCentre,Desktops,DN678901,1,true,,Lisa,Anderson
|
||||
MNB456789,Brother MFC-L8900CDW,Printers,DN789012,1,false,,,
|
||||
POI789012,Samsung Tab S7,Tablets,DN890123,1,true,,James,Taylor
|
||||
QAZ123456,Jabra Evolve 75,Accessories,DN901234,1,false,,,
|
||||
WSX345678,HP EliteBook 840,Laptops,DN012345,1,true,,Emma,Davis
|
||||
EDC567890,Google Pixel 6,Smartphones,DN123456,1,false,,,
|
||||
RFV789012,Acer Chromebook,Laptops,DN234567,1,true,,Daniel,Martin
|
||||
TGB901234,Epson WorkForce,Printers,DN345678,1,false,,,
|
||||
YHN234567,iMac 24-inch,Desktops,DN456789,1,true,,Sophia,Thompson
|
||||
|
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
|
||||
eslint: {
|
||||
// we have added a lint command to the package.json build script
|
||||
// which is why we disable the default next lint (during builds) here
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "stock-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"next-lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"db:push": "bunx prisma db push",
|
||||
"db:migrate": "bunx prisma migrate dev",
|
||||
"db:migrate:reset": "bunx prisma migrate reset",
|
||||
"db:deploy": "bunx prisma migrate deploy",
|
||||
"db:generate": "bunx prisma generate",
|
||||
"db:studio": "bunx prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/prisma/schema.prisma",
|
||||
"seed": "bun src/prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.518.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^5.0.0-beta.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/parser": "^8.34.1",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-playwright": "^2.2.0",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unicorn": "^59.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||
"prisma": "^6.10.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"@tailwindcss/oxide",
|
||||
"prisma",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
}
|
||||
|
||||
export default config
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
serialNumber;name;category;deliveryNote;stock;assigned;username;firstName;lastName
|
||||
|
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signInAction } from "@/lib/actions/auth.actions"
|
||||
import { SignInFormType, signInSchema } from "@/lib/schemas/auth.schemas"
|
||||
|
||||
export default function SignInForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const callbackUrl = searchParams.get("callbackUrl")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<SignInFormType>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: SignInFormType) => {
|
||||
const response = await signInAction(values)
|
||||
|
||||
if (response?.success) {
|
||||
router.push(callbackUrl || "/")
|
||||
}
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<label className="flex flex-col gap-1">
|
||||
Username
|
||||
<input
|
||||
{...register("username")}
|
||||
name="username"
|
||||
type="text"
|
||||
className="border-input w-full rounded-md border-2 p-2"
|
||||
/>
|
||||
{formState.errors.username && (
|
||||
<p className="text-error">{formState.errors.username.message}</p>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Password
|
||||
<input
|
||||
{...register("password")}
|
||||
name="password"
|
||||
type="password"
|
||||
className="border-input w-full rounded-md border-2 p-2"
|
||||
/>
|
||||
{formState.errors.password && (
|
||||
<p className="text-error">{formState.errors.password.message}</p>
|
||||
)}
|
||||
</label>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
<Button type="submit">Sign In</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
import SignInForm from "./_components/login-form"
|
||||
|
||||
export default async function LoginPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (session) redirect("/")
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignInForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function Card({
|
||||
title,
|
||||
total,
|
||||
icon,
|
||||
href,
|
||||
}: {
|
||||
title: string
|
||||
total: number
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-shadow hover:shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-4">{icon}</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import Card from "./_components/card"
|
||||
|
||||
export default async function Home() {
|
||||
const totalItems = await ItemService.findAllItemsCount()
|
||||
const totalAssets = await AssetService.findAllAssetsCount()
|
||||
const totalRecipients = await RecipientService.findAllRecipientsCount()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card
|
||||
title="Total Items"
|
||||
total={totalItems}
|
||||
href="/inventory/items"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v4a1 1 0 001 1h3m10-5h3a1 1 0 011 1v4m-5 5l-5 5m0 0l-5-5m5 5V2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Total Assets"
|
||||
total={totalAssets}
|
||||
href="/inventory/assets"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-4m-4 0H4"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Total Recipients"
|
||||
total={totalRecipients}
|
||||
href="/recipients"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { UpdateAssignmentFormType } from "@/lib/schemas/assignment.schemas"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import AssignmentForm from "../../_components/edit.assignment.form"
|
||||
export default async function EditAssignmentPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ assignamentId: string }>
|
||||
}) {
|
||||
const { assignamentId } = await params
|
||||
const assignment = await AssignmentService.findById(assignamentId)
|
||||
const recipients = await RecipientService.findAll()
|
||||
const items = await ItemService.findAllWithStock()
|
||||
const assets = await AssetService.findAll()
|
||||
|
||||
if (!assignment) {
|
||||
return <div>Assignment not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AssignmentForm
|
||||
recipients={recipients}
|
||||
items={items}
|
||||
assets={assets}
|
||||
initialData={assignment as UpdateAssignmentFormType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateAssignment } from "@/lib/actions/assignament.actions"
|
||||
import {
|
||||
UpdateAssignmentFormType,
|
||||
updateAssignmentSchema,
|
||||
} from "@/lib/schemas/assignment.schemas"
|
||||
import { Asset, Item, Recipient } from "@/lib/types"
|
||||
|
||||
interface Props {
|
||||
recipients: Recipient[]
|
||||
items: Item[]
|
||||
assets: Asset[]
|
||||
initialData: UpdateAssignmentFormType
|
||||
}
|
||||
|
||||
export default function EditAssignmentForm({
|
||||
recipients,
|
||||
items,
|
||||
assets,
|
||||
initialData,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<UpdateAssignmentFormType>({
|
||||
resolver: zodResolver(updateAssignmentSchema),
|
||||
defaultValues: {
|
||||
...initialData,
|
||||
id: initialData.id || undefined,
|
||||
},
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const itemId = watch("itemId")
|
||||
const assetId = watch("assetId")
|
||||
|
||||
const onSubmit = async (formData: UpdateAssignmentFormType) => {
|
||||
const response = await updateAssignment(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.values(response.errors as Record<string, string[]>).forEach(
|
||||
(messages) => {
|
||||
messages.forEach((msg) => toast.error(msg))
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/assignments")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.recipientId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
Item
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
{...register("itemId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.itemId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.itemId && <p className="text-error">{errors.itemId.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="assetId" className="mb-2 block text-lg">
|
||||
Asset
|
||||
</label>
|
||||
<select
|
||||
id="assetId"
|
||||
{...register("assetId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.assetId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an asset</option>
|
||||
{itemId
|
||||
? assets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.serialNumber}
|
||||
</option>
|
||||
))
|
||||
: null}
|
||||
</select>
|
||||
{errors.assetId && (
|
||||
<p className="text-error">{errors.assetId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
disabled={!itemId || assets.length > 0}
|
||||
min={1}
|
||||
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
|
||||
defaultValue={1}
|
||||
{...register("quantity")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
!itemId || assets.length > 0 ? "border-gray-300 bg-gray-100" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<p className="text-error">{errors.quantity.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!itemId || (assets.length > 0 && !assetId)}
|
||||
>
|
||||
Update Assignment
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createAssignment } from "@/lib/actions/assignament.actions"
|
||||
import {
|
||||
CreateAssignmentFormType,
|
||||
createAssignmentSchema,
|
||||
} from "@/lib/schemas/assignment.schemas"
|
||||
import { Asset, Item, Recipient } from "@/lib/types"
|
||||
|
||||
interface Props {
|
||||
recipients: Recipient[]
|
||||
items: Item[]
|
||||
assets: Asset[]
|
||||
}
|
||||
|
||||
export default function CreateAssignmentForm({
|
||||
recipients,
|
||||
items,
|
||||
assets,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<CreateAssignmentFormType>({
|
||||
resolver: zodResolver(createAssignmentSchema),
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const itemId = watch("itemId")
|
||||
const assetId = watch("assetId")
|
||||
|
||||
const itemAssets = useMemo(() => {
|
||||
return assets.filter((asset) => asset.itemId === itemId)
|
||||
}, [assets, itemId])
|
||||
|
||||
const onSubmit = async (formData: CreateAssignmentFormType) => {
|
||||
const response = await createAssignment(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.values(response.errors as Record<string, string[]>).forEach(
|
||||
(messages) => {
|
||||
messages.forEach((msg) => toast.error(msg))
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/assignments")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.recipientId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select a recipient</option>
|
||||
{recipients.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
Item
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
{...register("itemId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.itemId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an item</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.itemId && <p className="text-error">{errors.itemId.message}</p>}
|
||||
</div>
|
||||
{itemId && itemAssets.length !== 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="assetId" className="mb-2 block text-lg">
|
||||
Asset
|
||||
</label>
|
||||
<select
|
||||
id="assetId"
|
||||
{...register("assetId")}
|
||||
disabled={!itemId || itemAssets.length === 0}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
!itemId || itemAssets.length === 0
|
||||
? "border-gray-300 bg-gray-100"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an asset</option>
|
||||
{itemId
|
||||
? itemAssets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.serialNumber}
|
||||
</option>
|
||||
))
|
||||
: null}
|
||||
</select>
|
||||
{errors.assetId && (
|
||||
<p className="text-error">{errors.assetId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
disabled={!itemId || itemAssets.length > 0}
|
||||
min={1}
|
||||
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
|
||||
defaultValue={1}
|
||||
{...register("quantity")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
!itemId || itemAssets.length > 0
|
||||
? "border-gray-300 bg-gray-100"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<p className="text-error">{errors.quantity.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!itemId || (itemAssets.length > 0 && !assetId)}
|
||||
>
|
||||
Create Assignment
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { returnAssignment } from "@/lib/actions/assignament.actions"
|
||||
import { ReturnAssignmentFormType } from "@/lib/schemas/assignment.schemas"
|
||||
|
||||
export default function ReturnButton({
|
||||
assignmentId,
|
||||
}: {
|
||||
assignmentId: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleReturn = (formData: ReturnAssignmentFormType) => {
|
||||
startTransition(async () => {
|
||||
const response = await returnAssignment(formData)
|
||||
|
||||
if (!response.success && response.errors?.id) {
|
||||
toast.error(response.errors.id[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={() => handleReturn({ id: assignmentId })} className="w-full">
|
||||
<input type="hidden" name="id" value={assignmentId} />
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import AssignmentForm from "../_components/new.assignment.form"
|
||||
|
||||
export default async function NewAssignmentPage() {
|
||||
const recipients = await RecipientService.findAll()
|
||||
const items = await ItemService.findAllWithStock()
|
||||
const assets = await AssetService.findAllAvailable()
|
||||
|
||||
return (
|
||||
<AssignmentForm recipients={recipients} items={items} assets={assets} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
|
||||
import ReturnButton from "./_components/return.button"
|
||||
|
||||
export default async function AssignmentsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: assignments, totalPages } =
|
||||
await AssignmentService.findAllWithRecipient({
|
||||
page: currentPage,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Assignments"
|
||||
link="/assignments/new"
|
||||
search={search}
|
||||
data={assignments}
|
||||
/>
|
||||
{assignments.length === 0 && <div>No assignments found</div>}
|
||||
{assignments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Recipient
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Item
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assignments.map((assignment) => (
|
||||
<tr key={assignment.id} className="border-b">
|
||||
<td className="p-4">
|
||||
<Link
|
||||
href={`/recipients/${assignment?.recipient?.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{assignment?.recipient?.firstName}{" "}
|
||||
{assignment?.recipient?.lastName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Link
|
||||
href={`/inventory/items/${assignment?.item?.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{assignment?.item?.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{assignment?.asset?.serialNumber || "N/A"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/assignments/${assignment.id}/edit`}
|
||||
passHref
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
<ReturnButton assignmentId={assignment.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChangeEvent } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { importItems } from "@/lib/actions/import.actions"
|
||||
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
|
||||
export default function ImportForm({
|
||||
categories,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<ImportFormType>({
|
||||
resolver: zodResolver(importSchema),
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const file = watch("file")
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setValue("file", selectedFile, { shouldValidate: true })
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (formData: ImportFormType) => {
|
||||
const response = await importItems(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof ImportFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response?.message || "Asset created successfully")
|
||||
router.push("/inventory/items")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="file" className="mb-2 block text-lg">
|
||||
File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.file && <p className="text-error">{errors.file.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
{...register("categoryId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.categoryId && (
|
||||
<p className="text-error">{errors.categoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!file}
|
||||
>
|
||||
Import
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Download } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
|
||||
import ImportForm from "./_components/import.form"
|
||||
|
||||
export default async function ImportPage() {
|
||||
const categories = await CategoryService.findAllWithItemsCount()
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Mass Import</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Link href="/template.csv" download>
|
||||
<Button variant="outline">
|
||||
<Download />
|
||||
Download Template
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<ImportForm categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use server"
|
||||
|
||||
import { AssetWithAssignment } from "@/lib/types"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import EditAssetForm from "../../_components/edit.asset.form"
|
||||
|
||||
export default async function EditAssetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ assetId: string }>
|
||||
}) {
|
||||
const { assetId } = await params
|
||||
const items = await ItemService.findAll()
|
||||
const recipients = await RecipientService.findAll()
|
||||
const asset = await AssetService.findById(assetId)
|
||||
|
||||
if (!asset) {
|
||||
return <div>Asset not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit Asset</h1>
|
||||
</div>
|
||||
<EditAssetForm
|
||||
items={items}
|
||||
recipients={recipients}
|
||||
asset={asset as unknown as AssetWithAssignment}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { ItemStatus } from "@/generated/prisma/client"
|
||||
import { updateAssetAction } from "@/lib/actions/asset.actions"
|
||||
import {
|
||||
UpdateAssetFormType,
|
||||
updateAssetSchema,
|
||||
} from "@/lib/schemas/asset.schemas"
|
||||
import {
|
||||
AssetWithAssignment,
|
||||
Item,
|
||||
Recipient,
|
||||
UpdateAssetStatus,
|
||||
} from "@/lib/types"
|
||||
|
||||
interface EditAssetFormProps {
|
||||
asset: AssetWithAssignment
|
||||
items: Item[]
|
||||
recipients: Recipient[]
|
||||
}
|
||||
|
||||
export default function EditAssetForm({
|
||||
asset,
|
||||
items,
|
||||
recipients,
|
||||
}: EditAssetFormProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<UpdateAssetFormType>({
|
||||
resolver: zodResolver(updateAssetSchema),
|
||||
defaultValues: {
|
||||
id: asset.id,
|
||||
itemId: asset.itemId ?? "",
|
||||
serialNumber: asset.serialNumber,
|
||||
deliveryNote: asset.deliveryNote ?? "",
|
||||
status: asset.status as UpdateAssetStatus,
|
||||
recipientId: asset.assignment?.recipientId ?? "",
|
||||
},
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const status = watch("status")
|
||||
|
||||
const onSubmit = async (formData: UpdateAssetFormType) => {
|
||||
const response = await updateAssetAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof UpdateAssetFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success("Asset updated successfully")
|
||||
router.push(`/inventory/assets`)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Item
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
{...register("itemId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a item:</option>
|
||||
{items?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.itemId && (
|
||||
<p className="text-error">{errors.itemId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serialNumber" className="mb-2 block text-lg">
|
||||
Serial Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serialNumber"
|
||||
placeholder="Serial number"
|
||||
{...register("serialNumber")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.serialNumber && (
|
||||
<p className="text-error">{errors?.serialNumber?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
|
||||
Delivery Note
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryNote"
|
||||
placeholder="Delivery note"
|
||||
{...register("deliveryNote")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.deliveryNote && (
|
||||
<p className="text-error">{errors.deliveryNote.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="mb-2 block text-lg">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
{...register("status")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a status</option>
|
||||
{Object.values(ItemStatus).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.status && (
|
||||
<p className="text-error">{errors.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{status === "ASSIGNED" && (
|
||||
<div>
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a Recipient</option>
|
||||
{recipients?.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Asset
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { ItemStatus } from "@/generated/prisma/client"
|
||||
import { createAssetAction } from "@/lib/actions/asset.actions"
|
||||
import {
|
||||
CreateAssetFormType,
|
||||
createAssetSchema,
|
||||
} from "@/lib/schemas/asset.schemas"
|
||||
import { ItemWithoutStock, Recipient } from "@/lib/types"
|
||||
|
||||
interface NewAssetFormProps {
|
||||
items: ItemWithoutStock[]
|
||||
recipients: Recipient[]
|
||||
}
|
||||
|
||||
export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<CreateAssetFormType>({
|
||||
resolver: zodResolver(createAssetSchema),
|
||||
defaultValues: {
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const status = watch("status")
|
||||
|
||||
const onSubmit = async (formData: CreateAssetFormType) => {
|
||||
const response = await createAssetAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreateAssetFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success("Asset created successfully")
|
||||
router.push(`/inventory/assets`)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Item
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
{...register("itemId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a item:</option>
|
||||
{items?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.itemId && (
|
||||
<p className="text-error">{errors.itemId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serialNumber" className="mb-2 block text-lg">
|
||||
Serial Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serialNumber"
|
||||
placeholder="Serial number"
|
||||
{...register("serialNumber")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.serialNumber && (
|
||||
<p className="text-error">{errors?.serialNumber?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
|
||||
Delivery Note
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryNote"
|
||||
placeholder="Delivery note"
|
||||
{...register("deliveryNote")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.deliveryNote && (
|
||||
<p className="text-error">{errors.deliveryNote.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="mb-2 block text-lg">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
{...register("status")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a status</option>
|
||||
{Object.values(ItemStatus).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.status && (
|
||||
<p className="text-error">{errors.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{status === "ASSIGNED" && (
|
||||
<div>
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a Recipient</option>
|
||||
{recipients?.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Asset
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use server"
|
||||
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import NewAssetForm from "../_components/new.asset.form"
|
||||
|
||||
export default async function NewAssetPage() {
|
||||
const items = await ItemService.findAllAssignable()
|
||||
const recipients = await RecipientService.findAll()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New Asset</h1>
|
||||
</div>
|
||||
<NewAssetForm items={items} recipients={recipients} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
|
||||
export default async function AssetsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: assets, totalPages } =
|
||||
await AssetService.findAllWithItemAndCategory({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Assets"
|
||||
link="/inventory/assets/new"
|
||||
data={assets}
|
||||
search={search}
|
||||
/>
|
||||
{assets.length === 0 && currentPage === 1 && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No Assets found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{assets.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Item Name
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Category
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.map((asset) => (
|
||||
<tr key={asset.id} className="border-b">
|
||||
<td className="p-4">{asset.item?.name}</td>
|
||||
<td className="p-4">{asset.item?.category?.name}</td>
|
||||
<td className="p-4">{asset.serialNumber}</td>
|
||||
<td className="p-4">{asset.status}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
|
||||
import EditCategoryForm from "../../_components/edit.category.form"
|
||||
|
||||
export default async function EditCategoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ categoryId: string }>
|
||||
}) {
|
||||
const { categoryId } = await params
|
||||
const category = await CategoryService.findById(categoryId)
|
||||
|
||||
if (!category) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit Category</h1>
|
||||
</div>
|
||||
<EditCategoryForm category={category} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { Trash } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { deleteCategoryAction } from "@/lib/actions/category.actions"
|
||||
|
||||
export default function DeleteCategoryButton({
|
||||
categoryId,
|
||||
}: {
|
||||
categoryId: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDelete = (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await deleteCategoryAction(formData)
|
||||
|
||||
if (!response.success && response.errors?.id) {
|
||||
toast.error(response.errors.id[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleDelete}>
|
||||
<input type="hidden" name="id" value={categoryId} />
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateCategoryAction } from "@/lib/actions/category.actions"
|
||||
import {
|
||||
UpdateCategoryFormType,
|
||||
updateCategorySchema,
|
||||
} from "@/lib/schemas/category.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
|
||||
export default function EditCategoryForm({
|
||||
category,
|
||||
}: {
|
||||
category: CategorySummary
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateCategoryFormType>({
|
||||
resolver: zodResolver(updateCategorySchema),
|
||||
defaultValues: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UpdateCategoryFormType) => {
|
||||
const response = await updateCategoryAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof UpdateCategoryFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/categories")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Category name"
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.name ? "border-error" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Category
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createCategoryAction } from "@/lib/actions/category.actions"
|
||||
import {
|
||||
CreateCategoryFormType,
|
||||
createCategorySchema,
|
||||
} from "@/lib/schemas/category.schemas"
|
||||
|
||||
export default function NewCategoryForm() {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateCategoryFormType>({
|
||||
resolver: zodResolver(createCategorySchema),
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateCategoryFormType) => {
|
||||
const response = await createCategoryAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreateCategoryFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/categories")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Category name"
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.name ? "border-error" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Category
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import NewCategoryForm from "../_components/new.category.form"
|
||||
|
||||
export default function NewCategoryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New Category</h1>
|
||||
</div>
|
||||
<NewCategoryForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
|
||||
import DeleteCategoryButton from "./_components/delete.category.button"
|
||||
|
||||
export default async function Items(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: categories, totalPages } =
|
||||
await CategoryService.findAllWithItemsCountPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Categories"
|
||||
link="/inventory/categories/new"
|
||||
data={categories}
|
||||
/>
|
||||
{categories.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No Categories found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Items
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="border-b">
|
||||
<td className="p-4">{category.name}</td>
|
||||
<td className="p-4">{category._count.items}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link
|
||||
href={`/inventory/categories/${category.id}/edit`}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
{category._count.items === 0 && (
|
||||
<DeleteCategoryButton categoryId={category.id} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={3} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
|
||||
import UpdateItemForm from "../../_components/update.item.form"
|
||||
|
||||
export default async function AddItem({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ itemId: string }>
|
||||
}) {
|
||||
const { itemId } = await params
|
||||
const categories = await CategoryService.findAll()
|
||||
const item = await ItemService.findByIdWithAssetCount(itemId)
|
||||
|
||||
if (!item) {
|
||||
return <div>Item not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{item?._count?.assets && item?._count.assets > 0 && (
|
||||
<div className="rounded-sm bg-red-100 p-4 text-red-800">
|
||||
<p>{`This item has already assets assigned to it.`}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit Item</h1>
|
||||
</div>
|
||||
<UpdateItemForm categories={categories} item={item} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { MovementService } from "@/services/movement.service"
|
||||
|
||||
export default async function ItemPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ itemId: string }>
|
||||
}) {
|
||||
const { itemId } = await params
|
||||
const item = await ItemService.findByIdWithCategory(itemId)
|
||||
const assets = await AssetService.findByItemId(itemId)
|
||||
const movements = await MovementService.findAllByItemId(itemId)
|
||||
|
||||
if (!item) {
|
||||
return <div>Item not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{item.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Category</span>
|
||||
<span>{item.category.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Stock</span>
|
||||
<span>{item.stock}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{assets?.length > 0 && (
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Assets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{assets?.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="grid grid-cols-3 gap-x-8 gap-y-2 text-sm"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status</span>
|
||||
<span>{asset.status || "Available"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Serial Number</span>
|
||||
<span>{asset.serialNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Delivery Note</span>
|
||||
<span>{asset.deliveryNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{assets?.length === 0 && (
|
||||
<p className="col-span-2 text-center text-gray-500">
|
||||
No assets found.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{movements?.length > 0 && (
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Movements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{movements.map((movement) => (
|
||||
<div
|
||||
key={`${movement.id}-${movement.type}`}
|
||||
className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Type</span>
|
||||
<span>{movement.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Quantity</span>
|
||||
<span>{movement.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { Trash } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { deleteItemAction } from "@/lib/actions/item.actions"
|
||||
|
||||
export default function DeleteItemButton({ itemId }: { itemId: string }) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDelete = (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await deleteItemAction(formData)
|
||||
|
||||
if (!response.success && response.errors?.id) {
|
||||
toast.error(response.errors.id[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleDelete}>
|
||||
<input type="hidden" name="id" value={itemId} />
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createItemAction } from "@/lib/actions/item.actions"
|
||||
import {
|
||||
CreateItemFormType,
|
||||
createItemSchema,
|
||||
} from "@/lib/schemas/item.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
|
||||
export default function NewItemForm({
|
||||
categories,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateItemFormType>({
|
||||
resolver: zodResolver(createItemSchema),
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateItemFormType) => {
|
||||
const response = await createItemAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreateItemFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/items ")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Item name"
|
||||
{...register("name")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
{errors?.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
{...register("categoryId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.categoryId && (
|
||||
<p className="text-error">{errors.categoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stock" className="mb-2 block text-lg">
|
||||
Stock
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
pattern="{[0-9]*}"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
{...register("stock")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!/[0-9]/.test(event.key)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.value = event.currentTarget.value.slice(
|
||||
0,
|
||||
event.currentTarget.value.length - 1,
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Item
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateItemAction } from "@/lib/actions/item.actions"
|
||||
import {
|
||||
UpdateItemFormType,
|
||||
updateItemSchema,
|
||||
} from "@/lib/schemas/item.schemas"
|
||||
import { CategorySummary, ItemWithAssetCount } from "@/lib/types"
|
||||
|
||||
export default function UpdateItemForm({
|
||||
categories,
|
||||
item,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
item: ItemWithAssetCount
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const isDisabled = !!item?._count.assets && item?._count.assets > 0
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateItemFormType>({
|
||||
resolver: zodResolver(updateItemSchema),
|
||||
defaultValues: {
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
categoryId: item?.category.id,
|
||||
stock: item?.stock,
|
||||
},
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UpdateItemFormType) => {
|
||||
const response = await updateItemAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof UpdateItemFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/inventory/items ")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
{item?.id && <input type="hidden" name="id" value={item.id} />}
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Item name"
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
// disabled={isDisabled}
|
||||
{...register("categoryId")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.categoryId && (
|
||||
<p className="text-error">{errors.categoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stock" className="mb-2 block text-lg">
|
||||
Stock
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
pattern="{[0-9]*}"
|
||||
placeholder="0"
|
||||
min={item.stock}
|
||||
disabled={isDisabled}
|
||||
{...register("stock")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
isDisabled ? "bg-gray-100" : ""
|
||||
}`}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!/[0-9]/.test(event.key)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.value = event.currentTarget.value.slice(
|
||||
0,
|
||||
event.currentTarget.value.length - 1,
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Item
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
|
||||
import NewItemForm from "../_components/new.item.form"
|
||||
|
||||
export default async function NewItemPage() {
|
||||
const categories = await CategoryService.findAll()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New Item</h1>
|
||||
</div>
|
||||
<NewItemForm categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Eye, Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
|
||||
import DeleteItemButton from "./_components/delete.item.button"
|
||||
|
||||
export default async function ItemsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: items, totalPages } = await ItemService.findAllWithAssetCount({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Items"
|
||||
link="/inventory/items/new"
|
||||
data={items}
|
||||
search={search}
|
||||
/>
|
||||
{items.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No items found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Category
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Assets
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Stock
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b">
|
||||
<td className="p-4">{item.name}</td>
|
||||
<td className="p-4">{item.category.name}</td>
|
||||
<td className="p-4">{item._count.assets}</td>
|
||||
<td className="p-4">{item.stock}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/inventory/items/${item.id}`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Eye />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/inventory/items/${item.id}/edit`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
{item._count.assets === 0 && item.stock === 0 && (
|
||||
<DeleteItemButton itemId={item.id} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
import Navbar from "@/components/layout/navbar"
|
||||
import AppSidebar from "@/components/layout/sidebar"
|
||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||
|
||||
export default async function LayoutDashboard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="w-full">
|
||||
<Navbar />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { MovementService } from "@/services/movement.service"
|
||||
|
||||
export default async function MovementsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const { data: movements, totalPages } = await MovementService.findAll({
|
||||
page: currentPage,
|
||||
pageSize: 12,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Movements</h1>
|
||||
</div>
|
||||
{movements.length === 0 && <div>No movements found</div>}
|
||||
{movements.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Item
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Quantity
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Recipient
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((movement) => (
|
||||
<tr key={movement.id} className="border-b">
|
||||
<td className="p-4">{movement.type}</td>
|
||||
<td className="p-4">{movement?.item?.name}</td>
|
||||
<td className="p-4">
|
||||
{movement?.asset?.serialNumber || "-"}
|
||||
</td>
|
||||
<td className="p-4">{movement.quantity}</td>
|
||||
<td className="p-4">
|
||||
{movement?.recipient?.firstName || "-"}{" "}
|
||||
{movement?.recipient?.lastName || "-"}
|
||||
</td>
|
||||
<td className="p-4">{formatDate(movement.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
import RecipientForm from "../../_components/recipient.form"
|
||||
|
||||
export default async function RecipientEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ recipientId: string }>
|
||||
}) {
|
||||
const { recipientId } = await params
|
||||
const recipient = await RecipientService.findById(recipientId)
|
||||
|
||||
if (!recipient) {
|
||||
return <div>Recipient not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit Recipient</h1>
|
||||
</div>
|
||||
<RecipientForm initialData={recipient} mode="edit" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
export default async function RecipientInfoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ recipientId: string }>
|
||||
}) {
|
||||
const { recipientId } = await params
|
||||
const recipient = await RecipientService.findById(recipientId)
|
||||
const assignments = await AssignmentService.findAllByRecipient(recipientId)
|
||||
|
||||
if (!recipient) {
|
||||
return <div>Recipient not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{recipient.firstName + " " + recipient.lastName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Username</span>
|
||||
<span>{recipient.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Email</span>
|
||||
<span>{recipient.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Phone</span>
|
||||
<span>{recipient.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Department</span>
|
||||
<span>{recipient.department}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{assignments.length > 0 && (
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Assignments</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-y-2 text-sm">
|
||||
{assignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex w-full justify-between"
|
||||
>
|
||||
<span className="text-gray-600">{assignment.item?.name}</span>
|
||||
<span>{assignment.asset?.serialNumber}</span>
|
||||
<span>{assignment.quantity || 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { RecipientDepartment } from "@/generated/prisma/client"
|
||||
import {
|
||||
createNewRecipient,
|
||||
updateRecipient,
|
||||
} from "@/lib/actions/recipient.actions"
|
||||
import {
|
||||
CreateRecipientFormType,
|
||||
recipientSchema,
|
||||
UpdateRecipientFormType,
|
||||
} from "@/lib/schemas/recipients.schemas"
|
||||
import { Recipient } from "@/lib/types"
|
||||
|
||||
interface RecipientFormProps {
|
||||
initialData?: Recipient
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
export default function RecipientForm({
|
||||
initialData,
|
||||
mode = "create",
|
||||
}: RecipientFormProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateRecipientFormType>({
|
||||
resolver: zodResolver(recipientSchema),
|
||||
defaultValues: {
|
||||
id: initialData?.id || "",
|
||||
username: initialData?.username || "",
|
||||
firstName: initialData?.firstName || "",
|
||||
lastName: initialData?.lastName || "",
|
||||
department: initialData?.department || "OTHER",
|
||||
email: initialData?.email || "",
|
||||
phone: initialData?.phone || "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateRecipientFormType) => {
|
||||
const response =
|
||||
mode === "create"
|
||||
? await createNewRecipient(formData)
|
||||
: await updateRecipient(formData as UpdateRecipientFormType)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreateRecipientFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/recipients")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="username" className="mb-2 block text-lg">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Username"
|
||||
{...register("username")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.username && (
|
||||
<p className="text-error">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="firstName" className="mb-2 block text-lg">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
placeholder="First Name"
|
||||
{...register("firstName")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.firstName && (
|
||||
<p className="text-error">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="mb-2 block text-lg">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
placeholder="Last Name"
|
||||
{...register("lastName")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.lastName && (
|
||||
<p className="text-error">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
Department
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register("department")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a department</option>
|
||||
{Object.keys(RecipientDepartment).map((department) => (
|
||||
<option key={department} value={department}>
|
||||
{department}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.department && (
|
||||
<p className="text-error">{errors.department.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-lg">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
{...register("email")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.email && <p className="text-error">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="mb-2 block text-lg">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="phone"
|
||||
placeholder="Phone"
|
||||
{...register("phone")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.phone && <p className="text-error">{errors.phone.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{mode === "create" ? "Create Recipient" : "Update Recipient"}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import RecipientForm from "../_components/recipient.form"
|
||||
|
||||
export default function NewRecipientPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Add Recipient</h1>
|
||||
</div>
|
||||
<RecipientForm mode="create" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Eye, Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Recipient } from "@/generated/prisma/client"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
|
||||
export default async function RecipientsPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: recipients, totalPages } =
|
||||
await RecipientService.findAllPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Recipients"
|
||||
link="/recipients/new"
|
||||
data={recipients}
|
||||
search={search}
|
||||
/>
|
||||
{recipients.length === 0 && <div>No recipients found</div>}
|
||||
{recipients.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Username
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Phone
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Department
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recipients.map((recipient: Recipient) => (
|
||||
<tr key={recipient.id} className="border-b">
|
||||
<td className="p-4">{recipient.username}</td>
|
||||
<td className="p-4">
|
||||
{recipient.firstName + " " + recipient.lastName}
|
||||
</td>
|
||||
<td className="p-4">{recipient.email}</td>
|
||||
<td className="p-4">{recipient.phone}</td>
|
||||
<td className="p-4">{recipient.department}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/recipients/${recipient.id}`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Eye />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/recipients/${recipient.id}/edit`} passHref>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/lib/auth" // Referring to the auth.ts we just created
|
||||
export const { GET, POST } = handlers
|
||||
@@ -0,0 +1,39 @@
|
||||
import { exec } from "child_process"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { verifyUserRole } from "@/services/auth.service"
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await verifyUserRole("ADMIN")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return new NextResponse("Unauthorized", { status: 403 })
|
||||
}
|
||||
|
||||
return new Promise<NextResponse>((resolve) => {
|
||||
exec("bunx prisma migrate reset --force", (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`Error: ${error.message}`)
|
||||
return resolve(
|
||||
new NextResponse("Error al resetear la base de datos", {
|
||||
status: 500,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`)
|
||||
return resolve(
|
||||
new NextResponse("Error al resetear la base de datos", {
|
||||
status: 500,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`stdout: ${stdout}`)
|
||||
return resolve(
|
||||
new NextResponse("Base de datos reseteada con éxito", { status: 200 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,35 @@
|
||||
import "@/styles/globals.css"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stock Manager",
|
||||
description: "Manage your inventory with ease",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signOut } from "@/lib/auth"
|
||||
|
||||
export function SignOut() {
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server"
|
||||
await signOut()
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="destructive">
|
||||
Sign Out
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Plus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import Search from "@/components/common/search"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title?: string
|
||||
link?: string
|
||||
search?: string
|
||||
data: any[]
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
link,
|
||||
search,
|
||||
data,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="mb-4 flex w-full flex-col gap-4 md:flex-row">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<Search hidden={data.length === 0 && !search} />
|
||||
</div>
|
||||
{link && (
|
||||
<div className="justify-end md:ml-auto md:flex">
|
||||
<Link href={link} passHref>
|
||||
<Button className="btn btn-primary">
|
||||
Add {title} <Plus />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
export default function PaginationButtons({
|
||||
totalPages,
|
||||
}: {
|
||||
totalPages: number
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const currentPage = Number(searchParams.get("page")) || 1
|
||||
|
||||
const createPageURL = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set("page", pageNumber.toString())
|
||||
router.push(`${pathname}?${params.toString()}`)
|
||||
return `${pathname}?${params.toString()}`
|
||||
}
|
||||
|
||||
const getPageNumbers = () => {
|
||||
let start = Math.max(1, currentPage - 1)
|
||||
let end = Math.min(totalPages, currentPage + 1)
|
||||
|
||||
// Always try to show 3 pages if possible
|
||||
if (end - start < 2) {
|
||||
if (start === 1) {
|
||||
end = Math.min(totalPages, start + 2)
|
||||
} else if (end === totalPages) {
|
||||
start = Math.max(1, end - 2)
|
||||
}
|
||||
}
|
||||
|
||||
const pages = []
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
const pageNumbers = getPageNumbers()
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
Showing page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
{currentPage > 1 && (
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => createPageURL(currentPage - 1)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</PaginationPrevious>
|
||||
</PaginationItem>
|
||||
)}
|
||||
{pageNumbers.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => createPageURL(page)}
|
||||
isActive={page === currentPage}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
{currentPage < totalPages && (
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => createPageURL(currentPage + 1)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</PaginationNext>
|
||||
</PaginationItem>
|
||||
)}
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { Search as SearchIcon, X } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDebouncedCallback } from "use-debounce"
|
||||
|
||||
import { Input } from "../ui/input"
|
||||
|
||||
interface SearchProps {
|
||||
paramKey?: string
|
||||
placeholder?: string
|
||||
[x: string]: any
|
||||
}
|
||||
|
||||
export default function Search({
|
||||
paramKey = "search",
|
||||
placeholder = "Search...",
|
||||
...props
|
||||
}: SearchProps) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const initialValue = searchParams.get(paramKey) || ""
|
||||
const [search, setSearch] = useState(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialValue)
|
||||
}, [initialValue])
|
||||
|
||||
const updateSearchURL = useDebouncedCallback((value: string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
if (value) {
|
||||
params.delete("page")
|
||||
params.set(paramKey, value)
|
||||
} else {
|
||||
params.delete(paramKey)
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
replace(query ? `${pathname}?${query}` : pathname)
|
||||
}, 300)
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearch("")
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete(paramKey)
|
||||
const query = params.toString()
|
||||
replace(query ? `${pathname}?${query}` : pathname)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && document.activeElement !== inputRef.current) {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative max-w-sm md:w-full" {...props}>
|
||||
<SearchIcon
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-label="Buscar"
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
updateSearchURL(e.target.value)
|
||||
}}
|
||||
className="pr-9 pl-9"
|
||||
/>
|
||||
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
onClick={clearSearch}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Check, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean
|
||||
isSubmitSuccessful: boolean
|
||||
isSubmitError?: boolean
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SubmitButton({
|
||||
isSubmitting,
|
||||
isSubmitSuccessful,
|
||||
disabled,
|
||||
children = "Submit",
|
||||
...props
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
disabled={disabled || isSubmitting || isSubmitSuccessful}
|
||||
{...props}
|
||||
>
|
||||
{!isSubmitting && !isSubmitSuccessful && children}
|
||||
{isSubmitting && (
|
||||
<>
|
||||
Processing
|
||||
<Loader2 className="animate-spin" />
|
||||
</>
|
||||
)}
|
||||
{isSubmitSuccessful && (
|
||||
<>
|
||||
Success
|
||||
<Check className="ml-2 animate-pulse text-green-500" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Plus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu"
|
||||
import ResetButton from "./resetButton"
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: "Category",
|
||||
href: "/inventory/categories/new",
|
||||
},
|
||||
{
|
||||
name: "Item",
|
||||
href: "/inventory/items/new",
|
||||
},
|
||||
{
|
||||
name: "Asset",
|
||||
href: "/inventory/assets/new",
|
||||
},
|
||||
{
|
||||
name: "Recipient",
|
||||
href: "/recipients/new",
|
||||
},
|
||||
{
|
||||
name: "Assignment",
|
||||
href: "/assignments/new",
|
||||
},
|
||||
]
|
||||
|
||||
export default function AddMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" className="btn btn-primary">
|
||||
<Plus />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/import"
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
passHref
|
||||
>
|
||||
<Plus /> Import
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem key={item.name} asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
passHref
|
||||
>
|
||||
<Plus /> {item.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<ResetButton />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { SIGN_IN_URL } from "@/lib/constants"
|
||||
|
||||
import { SignOut } from "../auth/logout"
|
||||
import { SidebarTrigger } from "../ui/sidebar"
|
||||
import AddMenu from "./addMenu"
|
||||
|
||||
export default async function Navbar() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session) redirect(SIGN_IN_URL)
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between border-b p-4">
|
||||
<SidebarTrigger />
|
||||
<div className="flex items-center gap-4">
|
||||
<AddMenu />
|
||||
<div className="flex items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex cursor-pointer items-center space-x-3">
|
||||
{session && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{session.user?.name}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{session.user?.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative inline-flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-amber-400">
|
||||
{session && (
|
||||
<span className="font-medium text-white">
|
||||
{session.user?.name?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<SignOut />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2, Trash } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ResetButton() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleReset = async () => {
|
||||
setLoading(true)
|
||||
const response = await fetch("/api/db/reset", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Database reseted successfully")
|
||||
signOut()
|
||||
router.push("/login")
|
||||
} else {
|
||||
toast.error("Error resetting database")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm bg-red-500 px-2 py-1.5 text-sm text-white outline-hidden hover:bg-red-600"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <Trash />}
|
||||
{loading ? "Resetting..." : "Reset Database"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Clipboard,
|
||||
Home,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import { SidebarSection } from "./sidebar/sidebarSection"
|
||||
|
||||
const items = [
|
||||
{
|
||||
type: "item",
|
||||
title: "Home",
|
||||
url: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
title: "Inventory",
|
||||
url: "#",
|
||||
icon: Package,
|
||||
items: [
|
||||
{
|
||||
title: "Items",
|
||||
url: "/inventory/items",
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
url: "/inventory/categories",
|
||||
},
|
||||
{
|
||||
title: "Assets",
|
||||
url: "/inventory/assets",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
title: "Recipients",
|
||||
url: "/recipients",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
title: "Movements",
|
||||
url: "/movements",
|
||||
icon: BarChart,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
title: "Assignments",
|
||||
url: "/assignments",
|
||||
icon: Clipboard,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AppSidebar({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader className="flex items-center gap-2 p-4">
|
||||
<Link href="/" className="flex flex-col items-center gap-2">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
<span>Stock Manager</span>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="flex flex-col gap-1 p-2">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item, index) => {
|
||||
if (item.type === "item") {
|
||||
const isActive =
|
||||
item.url === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.url)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={`item-${index}`}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
if (item.type === "section") {
|
||||
return (
|
||||
<SidebarSection
|
||||
key={`section-${index}`}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
items={item.items}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function SidebarSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
items: { title: string; url: string }[] | undefined
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const isAnySubActive = items?.some((sub) => pathname.startsWith(sub.url))
|
||||
const [open, setOpen] = useState(isAnySubActive)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(isAnySubActive)
|
||||
}, [isAnySubActive, pathname])
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className="group/collapsible w-full"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
<ChevronRight className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="ml-4 flex flex-col gap-1">
|
||||
{items?.map((subItem, i) => {
|
||||
const isActive = pathname.startsWith(subItem.url)
|
||||
return (
|
||||
<SidebarMenuSubItem key={i}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link href={subItem.url}>{subItem.title}</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open],
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./index"
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||
/* eslint-disable */
|
||||
module.exports = { ...require('.') }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./index"
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||
/* eslint-disable */
|
||||
module.exports = { ...require('.') }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./default"
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||
/* eslint-disable */
|
||||
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
|
||||
const {
|
||||
Decimal,
|
||||
objectEnumValues,
|
||||
makeStrictEnum,
|
||||
Public,
|
||||
getRuntime,
|
||||
skip
|
||||
} = require('./runtime/index-browser.js')
|
||||
|
||||
|
||||
const Prisma = {}
|
||||
|
||||
exports.Prisma = Prisma
|
||||
exports.$Enums = {}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 6.10.1
|
||||
* Query Engine version: 9b628578b3b7cae625e8c927178f15a170e74a9c
|
||||
*/
|
||||
Prisma.prismaVersion = {
|
||||
client: "6.10.1",
|
||||
engine: "9b628578b3b7cae625e8c927178f15a170e74a9c"
|
||||
}
|
||||
|
||||
Prisma.PrismaClientKnownRequestError = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`PrismaClientKnownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)};
|
||||
Prisma.PrismaClientUnknownRequestError = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`PrismaClientUnknownRequestError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.PrismaClientRustPanicError = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`PrismaClientRustPanicError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.PrismaClientInitializationError = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`PrismaClientInitializationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.PrismaClientValidationError = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`PrismaClientValidationError is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.Decimal = Decimal
|
||||
|
||||
/**
|
||||
* Re-export of sql-template-tag
|
||||
*/
|
||||
Prisma.sql = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`sqltag is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.empty = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`empty is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.join = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`join is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.raw = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`raw is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.validator = Public.validator
|
||||
|
||||
/**
|
||||
* Extensions
|
||||
*/
|
||||
Prisma.getExtensionContext = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`Extensions.getExtensionContext is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
Prisma.defineExtension = () => {
|
||||
const runtimeName = getRuntime().prettyName;
|
||||
throw new Error(`Extensions.defineExtension is unable to run in this browser environment, or has been bundled for the browser (running in ${runtimeName}).
|
||||
In case this error is unexpected for you, please report it in https://pris.ly/prisma-prisma-bug-report`,
|
||||
)}
|
||||
|
||||
/**
|
||||
* Shorthand utilities for JSON filtering
|
||||
*/
|
||||
Prisma.DbNull = objectEnumValues.instances.DbNull
|
||||
Prisma.JsonNull = objectEnumValues.instances.JsonNull
|
||||
Prisma.AnyNull = objectEnumValues.instances.AnyNull
|
||||
|
||||
Prisma.NullTypes = {
|
||||
DbNull: objectEnumValues.classes.DbNull,
|
||||
JsonNull: objectEnumValues.classes.JsonNull,
|
||||
AnyNull: objectEnumValues.classes.AnyNull
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Enums
|
||||
*/
|
||||
|
||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
});
|
||||
|
||||
exports.Prisma.UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
role: 'role',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.RecipientScalarFieldEnum = {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
department: 'department',
|
||||
email: 'email',
|
||||
phone: 'phone',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.CategoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ItemScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
categoryId: 'categoryId',
|
||||
stock: 'stock',
|
||||
minStock: 'minStock',
|
||||
maxStock: 'maxStock',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
deletedAt: 'deletedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AssetScalarFieldEnum = {
|
||||
id: 'id',
|
||||
itemId: 'itemId',
|
||||
serialNumber: 'serialNumber',
|
||||
deliveryNote: 'deliveryNote',
|
||||
status: 'status',
|
||||
notes: 'notes',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AssignmentScalarFieldEnum = {
|
||||
id: 'id',
|
||||
quantity: 'quantity',
|
||||
notes: 'notes',
|
||||
itemId: 'itemId',
|
||||
assetId: 'assetId',
|
||||
recipientId: 'recipientId',
|
||||
assignmentDate: 'assignmentDate',
|
||||
returnDate: 'returnDate',
|
||||
createdBy: 'createdBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.MovementScalarFieldEnum = {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
quantity: 'quantity',
|
||||
details: 'details',
|
||||
notes: 'notes',
|
||||
itemId: 'itemId',
|
||||
assetId: 'assetId',
|
||||
previousStock: 'previousStock',
|
||||
newStock: 'newStock',
|
||||
recipientId: 'recipientId',
|
||||
assignmentId: 'assignmentId',
|
||||
userId: 'userId',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
};
|
||||
|
||||
exports.Prisma.QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
};
|
||||
|
||||
exports.Prisma.NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
};
|
||||
exports.UserRole = exports.$Enums.UserRole = {
|
||||
ADMIN: 'ADMIN',
|
||||
MANAGER: 'MANAGER',
|
||||
STAFF: 'STAFF',
|
||||
VIEWER: 'VIEWER'
|
||||
};
|
||||
|
||||
exports.RecipientDepartment = exports.$Enums.RecipientDepartment = {
|
||||
IT: 'IT',
|
||||
ENGINEERING: 'ENGINEERING',
|
||||
LOGISTICS: 'LOGISTICS',
|
||||
TRAFFIC: 'TRAFFIC',
|
||||
DRIVER: 'DRIVER',
|
||||
ADMINISTRATION: 'ADMINISTRATION',
|
||||
SALES: 'SALES',
|
||||
OTHER: 'OTHER'
|
||||
};
|
||||
|
||||
exports.ItemStatus = exports.$Enums.ItemStatus = {
|
||||
AVAILABLE: 'AVAILABLE',
|
||||
ASSIGNED: 'ASSIGNED',
|
||||
RESERVED: 'RESERVED',
|
||||
IN_REPAIR: 'IN_REPAIR',
|
||||
BROKEN: 'BROKEN',
|
||||
STOLEN: 'STOLEN',
|
||||
DISPOSED: 'DISPOSED'
|
||||
};
|
||||
|
||||
exports.MovementType = exports.$Enums.MovementType = {
|
||||
IN: 'IN',
|
||||
OUT: 'OUT',
|
||||
ASSIGNMENT: 'ASSIGNMENT',
|
||||
RETURN: 'RETURN',
|
||||
ADJUSTMENT: 'ADJUSTMENT',
|
||||
DELETED: 'DELETED'
|
||||
};
|
||||
|
||||
exports.Prisma.ModelName = {
|
||||
User: 'User',
|
||||
Recipient: 'Recipient',
|
||||
Category: 'Category',
|
||||
Item: 'Item',
|
||||
Asset: 'Asset',
|
||||
Assignment: 'Assignment',
|
||||
Movement: 'Movement'
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a stub Prisma Client that will error at runtime if called.
|
||||
*/
|
||||
class PrismaClient {
|
||||
constructor() {
|
||||
return new Proxy(this, {
|
||||
get(target, prop) {
|
||||
let message
|
||||
const runtime = getRuntime()
|
||||
if (runtime.isEdge) {
|
||||
message = `PrismaClient is not configured to run in ${runtime.prettyName}. In order to run Prisma Client on edge runtime, either:
|
||||
- Use Prisma Accelerate: https://pris.ly/d/accelerate
|
||||
- Use Driver Adapters: https://pris.ly/d/driver-adapters
|
||||
`;
|
||||
} else {
|
||||
message = 'PrismaClient is unable to run in this browser environment, or has been bundled for the browser (running in `' + runtime.prettyName + '`).'
|
||||
}
|
||||
|
||||
message += `
|
||||
If this is unexpected, please open an issue: https://pris.ly/prisma-prisma-bug-report`
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
exports.PrismaClient = PrismaClient
|
||||
|
||||
Object.assign(exports, Prisma)
|
||||
+15222
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "prisma-client-8fa07f1ca1555b6abbe80c6f50fa3e992025f58ff8812e13aca6a56a4a773a8c",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
"exports": {
|
||||
"./client": {
|
||||
"require": {
|
||||
"node": "./index.js",
|
||||
"edge-light": "./wasm.js",
|
||||
"workerd": "./wasm.js",
|
||||
"worker": "./wasm.js",
|
||||
"browser": "./index-browser.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"import": {
|
||||
"node": "./index.js",
|
||||
"edge-light": "./wasm.js",
|
||||
"workerd": "./wasm.js",
|
||||
"worker": "./wasm.js",
|
||||
"browser": "./index-browser.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"require": {
|
||||
"node": "./index.js",
|
||||
"edge-light": "./wasm.js",
|
||||
"workerd": "./wasm.js",
|
||||
"worker": "./wasm.js",
|
||||
"browser": "./index-browser.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"import": {
|
||||
"node": "./index.js",
|
||||
"edge-light": "./wasm.js",
|
||||
"workerd": "./wasm.js",
|
||||
"worker": "./wasm.js",
|
||||
"browser": "./index-browser.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./edge": {
|
||||
"types": "./edge.d.ts",
|
||||
"require": "./edge.js",
|
||||
"import": "./edge.js",
|
||||
"default": "./edge.js"
|
||||
},
|
||||
"./react-native": {
|
||||
"types": "./react-native.d.ts",
|
||||
"require": "./react-native.js",
|
||||
"import": "./react-native.js",
|
||||
"default": "./react-native.js"
|
||||
},
|
||||
"./extension": {
|
||||
"types": "./extension.d.ts",
|
||||
"require": "./extension.js",
|
||||
"import": "./extension.js",
|
||||
"default": "./extension.js"
|
||||
},
|
||||
"./index-browser": {
|
||||
"types": "./index.d.ts",
|
||||
"require": "./index-browser.js",
|
||||
"import": "./index-browser.js",
|
||||
"default": "./index-browser.js"
|
||||
},
|
||||
"./index": {
|
||||
"types": "./index.d.ts",
|
||||
"require": "./index.js",
|
||||
"import": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./wasm": {
|
||||
"types": "./wasm.d.ts",
|
||||
"require": "./wasm.js",
|
||||
"import": "./wasm.mjs",
|
||||
"default": "./wasm.mjs"
|
||||
},
|
||||
"./runtime/client": {
|
||||
"types": "./runtime/client.d.ts",
|
||||
"require": "./runtime/client.js",
|
||||
"import": "./runtime/client.mjs",
|
||||
"default": "./runtime/client.mjs"
|
||||
},
|
||||
"./runtime/library": {
|
||||
"types": "./runtime/library.d.ts",
|
||||
"require": "./runtime/library.js",
|
||||
"import": "./runtime/library.mjs",
|
||||
"default": "./runtime/library.mjs"
|
||||
},
|
||||
"./runtime/binary": {
|
||||
"types": "./runtime/binary.d.ts",
|
||||
"require": "./runtime/binary.js",
|
||||
"import": "./runtime/binary.mjs",
|
||||
"default": "./runtime/binary.mjs"
|
||||
},
|
||||
"./runtime/wasm-engine-edge": {
|
||||
"types": "./runtime/wasm-engine-edge.d.ts",
|
||||
"require": "./runtime/wasm-engine-edge.js",
|
||||
"import": "./runtime/wasm-engine-edge.mjs",
|
||||
"default": "./runtime/wasm-engine-edge.mjs"
|
||||
},
|
||||
"./runtime/wasm-compiler-edge": {
|
||||
"types": "./runtime/wasm-compiler-edge.d.ts",
|
||||
"require": "./runtime/wasm-compiler-edge.js",
|
||||
"import": "./runtime/wasm-compiler-edge.mjs",
|
||||
"default": "./runtime/wasm-compiler-edge.mjs"
|
||||
},
|
||||
"./runtime/edge": {
|
||||
"types": "./runtime/edge.d.ts",
|
||||
"require": "./runtime/edge.js",
|
||||
"import": "./runtime/edge-esm.js",
|
||||
"default": "./runtime/edge-esm.js"
|
||||
},
|
||||
"./runtime/react-native": {
|
||||
"types": "./runtime/react-native.d.ts",
|
||||
"require": "./runtime/react-native.js",
|
||||
"import": "./runtime/react-native.js",
|
||||
"default": "./runtime/react-native.js"
|
||||
},
|
||||
"./generator-build": {
|
||||
"require": "./generator-build/index.js",
|
||||
"import": "./generator-build/index.js",
|
||||
"default": "./generator-build/index.js"
|
||||
},
|
||||
"./sql": {
|
||||
"require": {
|
||||
"types": "./sql.d.ts",
|
||||
"node": "./sql.js",
|
||||
"default": "./sql.js"
|
||||
},
|
||||
"import": {
|
||||
"types": "./sql.d.ts",
|
||||
"node": "./sql.mjs",
|
||||
"default": "./sql.mjs"
|
||||
},
|
||||
"default": "./sql.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"version": "6.10.1",
|
||||
"sideEffects": false
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user