first version

This commit is contained in:
2025-11-12 15:30:12 +01:00
commit f668b6f006
161 changed files with 31955 additions and 0 deletions
+9
View File
@@ -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
+29
View File
@@ -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:
+42
View File
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
.git
.devcontainer
.vscode
.next/
Dockerfile
compose.yml
node_modules
README.md
*.csv
+14
View File
@@ -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
BIN
View File
Binary file not shown.
+41
View File
@@ -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
+15
View File
@@ -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
View File
@@ -0,0 +1,11 @@
{
"useTabs": false,
"trailingComma": "all",
"semi": false,
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"endOfLine": "auto",
"arrowParens": "always",
"plugins": ["prettier-plugin-tailwindcss"]
}
+20
View File
@@ -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
View File
@@ -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"]
+236
View File
@@ -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.
+1122
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
+80
View File
@@ -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
+43
View File
@@ -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:
+59
View File
@@ -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[]
Executable
+22
View File
@@ -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
1 serialNumber name category deliveryNote stock assigned username firstName lastName
2 HDMI Cable Peripherals 10 false
3 ABC123456 iPhone 13 Pro Smartphones DN789012 1 false
4 XYZ789012 Dell Latitude 5420 Laptops DN345678 1 true jsmith John Smith
5 QWE345678 HP LaserJet Pro Printers DN901234 1 false
6 MNB567890 iPad Air Tablets DN567890 1 true sjones Sarah Jones
7 JKL901234 Logitech MX Master Peripherals DN123456 1 false
8 POI234567 ThinkPad X1 Carbon Laptops DN678901 1 true mbrown Michael Brown
9 ZXC890123 Samsung Galaxy S21 Smartphones DN234567 1 false
10 VBN345678 Microsoft Surface Pro Tablets DN890123 1 true ewilson Emily Wilson
11 UYT678901 Canon ImageRunner Printers DN456789 1 false
12 HGF123456 MacBook Air M1 Laptops DN012345 1 true dmiller David Miller
13 WER789012 AirPods Pro Accessories DN543210 1 false
14 LKJ234567 Lenovo ThinkCentre Desktops DN678901 1 true Lisa Anderson
15 MNB456789 Brother MFC-L8900CDW Printers DN789012 1 false
16 POI789012 Samsung Tab S7 Tablets DN890123 1 true James Taylor
17 QAZ123456 Jabra Evolve 75 Accessories DN901234 1 false
18 WSX345678 HP EliteBook 840 Laptops DN012345 1 true Emma Davis
19 EDC567890 Google Pixel 6 Smartphones DN123456 1 false
20 RFV789012 Acer Chromebook Laptops DN234567 1 true Daniel Martin
21 TGB901234 Epson WorkForce Printers DN345678 1 false
22 YHN234567 iMac 24-inch Desktops DN456789 1 true Sophia Thompson
+13
View File
@@ -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
+82
View File
@@ -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"
]
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
}
export default config
+1
View File
@@ -0,0 +1 @@
serialNumber;name;category;deliveryNote;stock;assigned;username;firstName;lastName
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>
)
}
+27
View File
@@ -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
View File
@@ -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>
)
}
+82
View File
@@ -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} />
)
}
+105
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+77
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+2
View File
@@ -0,0 +1,2 @@
import { handlers } from "@/lib/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
+39
View File
@@ -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

+35
View File
@@ -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>
)
}
+17
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+97
View File
@@ -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>
)
}
+98
View File
@@ -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>
)
}
+42
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+42
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}
+58
View File
@@ -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 }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-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,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -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 }
+135
View File
@@ -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,
}
+257
View File
@@ -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,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { 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,
}
+28
View File
@@ -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 }
+139
View File
@@ -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,
}
+726
View File
@@ -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,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+25
View File
@@ -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 }
+61
View File
@@ -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
View File
@@ -0,0 +1 @@
export * from "./index"
+4
View File
@@ -0,0 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
module.exports = { ...require('.') }
+1
View File
@@ -0,0 +1 @@
export * from "./index"
+4
View File
@@ -0,0 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
module.exports = { ...require('.') }
+1
View File
@@ -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)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+146
View File
@@ -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