1 Commits

305 changed files with 25511 additions and 26914 deletions
+8 -4
View File
@@ -10,7 +10,10 @@
// "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],
"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.
@@ -20,13 +23,14 @@
"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",
"biomejs.biome"
"csstools.postcss"
]
}
},
@@ -35,4 +39,4 @@
"containerEnv": {
"SHELL": "/bin/bash"
}
}
}
+1 -8
View File
@@ -13,11 +13,4 @@ NODE_ENV=production
DEMO_MODE=false
DOMAIN=localhost
AUTH_TRUST_HOST="http://localhost"
AUTH_SECRET=your_secret_key_here
STOCK_MANAGER_DEFAULT_LOCALE=en
# ADMIN BOOTSTRAP
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_EMAIL=admin@localhost
ADMIN_NAME=Administrator
ADMIN_PASSWORD=change-me
AUTH_SECRET=your_secret_key_here
-14
View File
@@ -12,16 +12,11 @@
# testing
/coverage
/test-results
/playwright-report
# next.js
/.next/
/out/
# prisma
src/generated
# production
/build
@@ -44,12 +39,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# vscode
!.vscode
# Local Pi runtime state
.atl/
.pi/
openspec/
sdd/
+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"]
}
+19 -18
View File
@@ -1,19 +1,20 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
"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"
}
}
-11
View File
@@ -1,11 +0,0 @@
# Changelog
## Unreleased
### Breaking Changes
- **W-3 (fix-assignment-asset-movement-audit)**: `Assignment.id` is no longer stable across person swaps. When `updateAssignmentUseCase` or `updateAssetUseCase` changes a person on an active assignment, the old assignment is now closed (with `closedAt`/`closedById` set; `AssignmentStockReturn` row created for QUANTITY; `AssignmentAssetLine.returnedAt` set for SERIALIZED) and a NEW `Assignment` is created with a new `id`. Any code that holds an `Assignment.id` expecting stability across person swaps must be updated to look up the new id (e.g., via the active `Assignment` for the person/asset). The change replaces a phantom `RETURN` + `ASSIGNMENT` pattern that wrote both movements to the same `Assignment.id` without creating an `AssignmentStockReturn` row.
## Released
_Unreleased changes appear above. Released versions will be added below as they are tagged._
+1 -1
View File
@@ -39,4 +39,4 @@ COPY --from=builder /app/.next/static ./.next/static
EXPOSE ${PORT}
CMD ["sh", "-c", "bun run db:deploy && bun run db:seed && bun run start"]
CMD ["bun", "run", "start"]
+172 -340
View File
@@ -1,406 +1,238 @@
# Stock Manager Home
Sistema de gestión de inventario, activos serializados, asignaciones y movimientos construido con Next.js, Prisma, PostgreSQL, NextAuth y Bun.
Sistema de gestión de inventario y asignación de activos desarrollado con Next.js 15, Prisma, PostgreSQL y NextAuth.
## Quick start
## 📋 Descripción
```bash
bun install
cp .env.example .env
bun run db:generate
bun run db:migrate
bun run db:seed
bun run dev
```
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.
Abrí la aplicación en [http://localhost:3000](http://localhost:3000).
## ✨ Características principales
> `db:seed` crea un administrador inicial cuando no existe ningún admin activo. Configurá las variables `ADMIN_*` en `.env` antes de usarlo en entornos compartidos o productivos.
### Gestión de Inventario
## Qué hace la aplicación
- **Í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
Stock Manager permite gestionar:
### Gestión de Destinatarios
- **Ítems genéricos**: productos gestionados por cantidad de stock.
- **Activos serializados**: equipos individuales con número de serie único.
- **Categorías**: clasificación de ítems y activos.
- **Personas**: personas o departamentos que reciben asignaciones. Cada persona puede tener un usuario del sistema vinculado o existir sin credenciales.
- **Asignaciones**: entrega y devolución de ítems o activos.
- **Movimientos**: historial auditable de entradas, salidas, asignaciones, devoluciones y ajustes.
- **Usuarios del sistema**: gestión unificada con personas, roles, estado activo y reseteo de contraseña.
- **Importación CSV**: flujo legacy de importación masiva, mantenido estructuralmente pero pendiente de rediseño.
- Registro de empleados/destinatarios por departamento
- Información de contacto (email, teléfono)
- Historial de asignaciones por destinatario
## Stack técnico
### Sistema de Asignaciones
| Área | Tecnología |
|------|------------|
| Framework | Next.js 16 App Router |
| Runtime/package manager | Bun |
| UI | React 19, Tailwind CSS, Radix UI/Shadcn-style components |
| Formularios | React Hook Form |
| Validación | Zod |
| Autenticación | NextAuth v5 |
| ORM | Prisma 7 |
| Base de datos | PostgreSQL |
| Formato/lint | Biome |
| Deploy | Docker / Docker Compose |
- 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
## Internacionalización (i18n)
### Movimientos e Historial
La aplicación soporta inglés (`en`) y español (`es`) en todas las superficies de usuario. La selección de idioma se persiste mediante una cookie `stock-manager-locale` validada en servidor y se aplica con un cambio de idioma por página sin rutas prefijadas.
- 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
Superficies localizadas:
### Importación de Datos
- **Login** y navbar compartida con selector de idioma compacto.
- **Shell común**: sidebar, navegación, search, paginación, botón submit, página de acceso denegado.
- **Inventario**: categorías, ítems, activos, personas, asignaciones, movimientos.
- **Personas**: gestión unificada de personas y usuarios del sistema.
- Importación masiva vía CSV
- Plantilla descargable para importaciones
- Validación de datos en el proceso de importación
La arquitectura i18n sigue un patrón consistente:
### Sistema de Autenticación y Roles
- **Diccionarios tipados** en `src/i18n/dictionaries/en.ts` y `es.ts` con paridad de claves obligatoria.
- **Resolución server-side**: las páginas obtienen `getI18n()` y pasan props acotadas a componentes cliente.
- **Schemas localizados**: builders (`buildCreateXSchema(copy)`) que aceptan copia de diccionario e inyectan mensajes de validación localizados.
- **Actions localizadas**: resuelven locale en servidor, construyen schemas con copia localizada y mapean errores de use-case mediante message mappers.
- **Datos de usuario vs UI**: nombres, emails, seriales y valores de enumeraciones canónicas nunca se traducen; solo se localizan las etiquetas de presentación.
- Autenticación segura con NextAuth v5
- 4 roles de usuario: ADMIN, MANAGER, STAFF, VIEWER
- Permisos diferenciados según rol
- Contraseñas hasheadas con bcrypt
La importación CSV queda fuera del alcance actual de i18n por su rediseño previsto.
## 🚀 Tecnologías
## Configuración de entorno
- **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
Copiá el ejemplo y completá los valores reales:
## 🔨 Desarrollo en entorno DevContainer
```bash
cp .env.example .env
```
Este proyecto incluye configuración para desarrollo en contenedor usando [DevContainer](https://containers.dev/).
Variables principales:
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.
| Grupo | Variables |
|-------|-----------|
| Base de datos | `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `POSTGRES_HOST`, `POSTGRES_PORT` |
| Auth | `AUTH_SECRET`, `AUTH_TRUST_HOST`, `DOMAIN`, `NODE_ENV`, `DEMO_MODE` |
| Bootstrap admin | `ADMIN_BOOTSTRAP_ENABLED`, `ADMIN_EMAIL`, `ADMIN_NAME`, `ADMIN_PASSWORD` |
### Bootstrap admin
El seed ejecuta `prisma/seed.ts`, que llama a `prisma/bootstrap-admin.ts`.
Comportamiento:
- Si ya existe un usuario `ADMIN` activo, no hace nada.
- Si `ADMIN_BOOTSTRAP_ENABLED=false`, no crea administrador.
- En producción, `ADMIN_PASSWORD` es obligatorio.
- En desarrollo, si no se define contraseña, usa un valor por defecto sólo para facilitar el arranque local.
Ejecutar manualmente:
```bash
bun run db:seed
```
## Desarrollo local
## 🔨 Desarrollo local
### Prerrequisitos
- Bun 1.3+
- PostgreSQL accesible mediante `DATABASE_URL`
- Docker disponible para tests con Testcontainers
- Node.js 18+ o Bun
- PostgreSQL 13+ (o usar Docker Compose)
- Git
### Pasos
1. Clonar el repositorio:
```bash
git clone <repo-url>
cd stock-manager
```
2. Instalar dependencias:
```bash
# 1. Instalar dependencias
bun install
# o
npm install
```
# 2. Configurar entorno
3. Configurar variables de entorno:
```bash
cp .env.example .env
```
# 3. Generar cliente Prisma
bun run db:generate
Editar `.env` con tus configuraciones:
# 4. Aplicar migraciones en desarrollo
```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
NODE_ENV=development
DEMO_MODE=false
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. Crear admin inicial, si corresponde
5. (Opcional) Ejecutar seed para datos iniciales:
```bash
bun run db:seed
```
# 6. Levantar Next
6. Iniciar el servidor de desarrollo:
```bash
bun run dev
```
## Desarrollo con DevContainer
Abrir [http://localhost:3000](http://localhost:3000) en el navegador.
El proyecto incluye configuración para desarrollo en contenedor.
## 🐳 Despliegue con Docker
1. Abrí el repo en VS Code.
2. Elegí **Reopen in Container**.
3. El entorno instala dependencias y puede levantar el servidor de desarrollo.
4. Accedé a [http://localhost:3000](http://localhost:3000).
## Docker / despliegue
Con Docker Compose:
### Producción
```bash
docker compose -f compose.yaml up -d
docker-compose -f compose.yaml up -d
```
El `Dockerfile` ejecuta al iniciar:
Con Traefik (reverse proxy):
```bash
bun run db:deploy && bun run db:seed && bun run start
docker-compose -f compose.yaml -f compose.traefik.yaml up -d
```
Esto aplica migraciones pendientes, ejecuta el bootstrap admin si corresponde y luego inicia Next.
## Scripts disponibles
| Script | Descripción |
|--------|-------------|
| `bun run dev` | Inicia Next en desarrollo con Turbopack |
| `bun run build` | Construye la aplicación para producción |
| `bun run start` | Inicia la build de producción |
| `bun run lint` | Ejecuta Biome lint con escritura de fixes |
| `bun run format` | Formatea con Biome |
| `bun run check` | Ejecuta Biome check con escritura de fixes |
| `bun run test` | Ejecuta toda la suite Vitest: unit + integración |
| `bun run test:unit` | Ejecuta unit tests rápidos |
| `bun run test:integration` | Genera Prisma y ejecuta integration tests con PostgreSQL Testcontainers |
| `bun run test:e2e` | Genera Prisma y ejecuta Playwright E2E smoke con DB aislada |
| `bun run test:coverage` | Ejecuta Vitest con coverage V8 |
| `bun run db:push` | Sincroniza el schema sin crear migraciones |
| `bun run db:migrate` | Crea/aplica migraciones en desarrollo |
| `bun run db:migrate:reset` | Resetea la base y reaplica migraciones |
| `bun run db:deploy` | Aplica migraciones en entornos de deploy |
| `bun run db:generate` | Genera el cliente Prisma en `src/generated/prisma` |
| `bun run db:seed` | Ejecuta el seed/bootstrap admin |
| `bun run db:studio` | Abre Prisma Studio |
## Prisma
El proyecto usa Prisma 7 con configuración en:
```txt
prisma.config.ts
prisma/schema.prisma
prisma/migrations/
```
El cliente Prisma se genera en:
```txt
src/generated/prisma
```
Ese directorio está ignorado por Git. Después de clonar, cambiar schema o instalar dependencias, ejecutá:
## 📜 Scripts disponibles
```bash
bun run db:generate
# 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)
```
Validar schema:
## 📁 Estructura del proyecto
```bash
bunx prisma validate
```
## Arquitectura del código
La aplicación separa responsabilidades por capa:
| Capa | Ruta | Responsabilidad |
|------|------|-----------------|
| UI / routes | `src/app` | Páginas, layouts y componentes por ruta |
| Server Actions | `src/actions` | Boundary de servidor: auth, Zod, llamada a use-case, revalidación |
| Use-cases | `src/use-cases` | Reglas de negocio, coordinación multi-entidad y transacciones |
| Services | `src/services` | Acceso a datos/repositories Prisma; muchos aceptan `tx` opcional |
| Schemas | `src/schemas` | Validación Zod y tipos de formularios/actions |
| Types | `src/types` | Tipos compartidos y aliases de Prisma |
| Lib | `src/lib` | Infraestructura común: auth, prisma, paginate, security, constants, utils |
### Regla práctica
- Las **Actions** deben ser finas.
- Las reglas de negocio viven en **use-cases**.
- Los **services** no orquestan flujos: leen/escriben datos y aceptan transacciones cuando participan en una operación mayor.
## Estructura principal
```txt
src/
├── actions/ # Server Actions finas
├── app/ # Next.js App Router
├── (auth)/ # Login
│ ├── (dashboard)/ # Dashboard, inventario, asignaciones, importación, people
│ ├── api/ # API routes
└── forbidden/ # Página de acceso denegado
├── components/ # Componentes compartidos y UI
├── generated/ # Cliente Prisma generado, ignorado por Git
├── hooks/ # Hooks React
├── lib/ # Infraestructura y utilidades
├── schemas/ # Schemas Zod
├── services/ # Repositories Prisma / read models
├── styles/ # Estilos globales
├── types/ # Tipos compartidos
└── use-cases/ # Casos de uso transaccionales
prisma/
├── bootstrap-admin.ts # Crea/activa admin inicial si corresponde
├── migrations/ # Migraciones Prisma
├── schema.prisma # Modelo de datos
└── seed.ts # Entry point de seed
├── 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
```
## Autenticación y autorización
## 🔐 Seguridad
- Login con NextAuth credentials.
- Passwords hasheadas con `bcryptjs`.
- Roles soportados: `ADMIN`, `MANAGER`, `STAFF`, `VIEWER`.
- `/people/*` requiere rol `ADMIN` para operaciones de gestión.
- Usuarios inactivos no pueden iniciar sesión.
- 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
Helpers relevantes:
## 🗃️ Modelo de datos
```txt
src/services/auth.service.ts
src/lib/auth.ts
src/proxy.ts
```
El sistema gestiona las siguientes entidades principales:
## Modelo de datos
- **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
Entidades principales:
| Entidad | Descripción |
|---------|-------------|
| `User` | Usuarios del sistema, roles y estados de ciclo de vida |
| `Person` | Personas del organigrama; pueden vincularse a un `User` |
| `Category` | Categorías de inventario |
| `Item` | Ítems genéricos con stock |
| `Asset` | Activos serializados |
| `Assignment` | Asignaciones y devoluciones |
| `Movement` | Historial auditable de movimientos |
Ver el schema completo en:
```txt
prisma/schema.prisma
```
## Flujos importantes
### Asignaciones
- Crear asignación decrementa stock de forma transaccional.
- Devolver asignación incrementa stock si aplica y libera activo.
- Movimientos `ASSIGNMENT` y `RETURN` se crean dentro del use-case.
### Activos
- Crear activo disponible incrementa stock.
- Crear activo asignado crea asignación y movimiento asociado.
- Cambios de estado generan movimientos adecuados (`IN`, `OUT`, `ASSIGNMENT`, `RETURN`, `ADJUSTMENT`).
### Ítems
- `Item.name` es único.
- Crear item con stock inicial genera movimiento `IN`.
- El borrado es soft delete y se bloquea si hay stock o assets asociados.
### Usuarios
- Sólo `ADMIN` puede gestionar personas y usuarios.
- No se puede quitar el propio acceso admin.
- No se puede dejar el sistema sin admin activo.
- La protección de último admin usa transacción serializable con retry de conflictos Prisma `P2034`.
## Transición de arquitectura
La aplicación está migrando de un modelo separado de usuarios y destinatarios hacia una gestión unificada de personas:
| Antes | Después |
|-------|---------|
| `Recipient` | `Person` |
| Gestión en `/admin/users` | Gestión en `/people` |
| Usuarios y personas desvinculados | Persona puede vincularse a un `User` opcional |
Estado actual:
- El schema Prisma y la migración inicial reflejan el nuevo modelo.
- Las rutas y componentes de UI ya fueron migrados a `/people`.
## Testing
El proyecto tiene una base inicial de tests en tres niveles:
| Nivel | Comando | Cobertura |
|-------|---------|-----------|
| Unit | `bun run test:unit` | Schemas Zod, helpers de seguridad y helpers de roles auth |
| Integración | `bun run test:integration` | Use-cases principales contra PostgreSQL real con Testcontainers |
| E2E smoke | `bun run test:e2e` | Login, dashboard, admin users, inventory items y assignments con Playwright |
### Integration tests
Los tests de integración viven en:
```txt
tests/integration/
```
Usan PostgreSQL real mediante Testcontainers. El helper de DB:
1. levanta un contenedor PostgreSQL aislado;
2. setea `DATABASE_URL` antes de importar Prisma/use-cases;
3. aplica migraciones con `prisma migrate deploy`;
4. limpia tablas entre tests.
> Importante: `src/lib/prisma.ts` lee `DATABASE_URL` al importarse. En tests, configurá el entorno antes de importar `@/lib/prisma`, services o use-cases.
### E2E smoke tests
Los tests E2E viven en:
```txt
tests/e2e/
```
Playwright levanta una app real contra una DB Testcontainers aislada y crea un admin determinístico para el smoke test.
El server E2E usa `next dev --webpack`. Next 16 puede usar Turbopack por defecto y durante la configuración inicial emitió un panic compilando `/assignments`; para E2E automatizado se fuerza Webpack por estabilidad.
### Secuencia completa recomendada
Antes de subir cambios grandes, ejecutá:
```bash
bun run test && bun run test:e2e && bunx tsc --noEmit && bunx prisma validate
```
## Validación antes de subir cambios
Ejecutá al menos:
```bash
bunx tsc --noEmit
bunx prisma validate
```
Para cambios de Prisma:
```bash
bun run db:generate
bunx prisma validate
```
Para cambios de formato/lint:
```bash
bun run check
```
## Estado conocido
- La importación CSV actual es legacy y se mantiene por compatibilidad; está previsto rediseñarla.
- El cliente Prisma generado no se versiona; debe generarse antes de build/deploy.
Ver `src/prisma/schema.prisma` para el esquema completo.
-66
View File
@@ -1,66 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"files": {
"includes": [
"**",
"!**/ node_modules",
"!**/ .next",
"!src/generated/prisma",
"!src/components/ui",
"!src/styles"
],
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"html": {
"formatter": {
"indentScriptAndStyle": false,
"selfCloseVoidElements": "always"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+536 -860
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
-4
View File
@@ -37,10 +37,6 @@ services:
DOMAIN: ${DOMAIN}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
AUTH_SECRET: ${AUTH_SECRET}
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-"true"}
ADMIN_EMAIL: ${ADMIN_EMAIL:-"admin@localhost"}
ADMIN_NAME: ${ADMIN_NAME:-"Administrator"}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
depends_on:
- db
+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[]
+6
View File
@@ -2,6 +2,12 @@ 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
+33 -37
View File
@@ -2,33 +2,29 @@
"name": "stock-manager",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.14",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome lint --write",
"format": "biome format --write",
"check": "biome check --write",
"test": "bunx vitest run",
"test:unit": "bunx vitest run tests/unit --passWithNoTests",
"test:integration": "bun run db:generate && bunx vitest run tests/integration",
"test:e2e": "bun run db:generate && bunx playwright test",
"test:coverage": "bunx vitest run --coverage",
"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:seed": "bunx --bun prisma db seed",
"db:studio": "bunx prisma studio"
},
"prisma": {
"schema": "src/prisma/schema.prisma",
"seed": "bun src/prisma/seed.ts"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@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",
@@ -40,40 +36,40 @@
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"lucide-react": "^1.17.0",
"next": "^16.2.4",
"lucide-react": "^0.518.0",
"next": "15.3.6",
"next-auth": "^5.0.0-beta.28",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"sonner": "^2.0.7",
"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": "^4.3.6"
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@playwright/test": "^1.60.0",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@testcontainers/postgresql": "^12.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"prisma": "^7.8.0",
"@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",
"testcontainers": "^12.0.1",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vitest": "^4.1.8"
"typescript": "^5.8.3"
},
"trustedDependencies": [
"@prisma/client",
@@ -83,4 +79,4 @@
"sharp",
"unrs-resolver"
]
}
}
-31
View File
@@ -1,31 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
const port = process.env.E2E_PORT ?? "3100"
const baseURL = `http://127.0.0.1:${port}`
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "**/*.spec.ts",
timeout: 60_000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
workers: 1,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun tests/e2e/support/e2e-server.ts",
url: baseURL,
timeout: 180_000,
reuseExistingServer: false,
},
})
-13
View File
@@ -1,13 +0,0 @@
import "dotenv/config"
import { defineConfig, env } from "prisma/config"
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "bun ./prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
})
-149
View File
@@ -1,149 +0,0 @@
import { fileURLToPath } from "node:url"
import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { getPasswordHash } from "@/lib/security"
import prisma from "../src/lib/prisma"
type BootstrapAdminInput = {
email: string
name: string
password: string
}
function splitName(name: string) {
const [firstName = "Administrator", ...rest] = name.trim().split(/\s+/)
return {
firstName,
lastName: rest.join(" "),
}
}
function getBootstrapAdminInput(): BootstrapAdminInput {
const isProduction = process.env.NODE_ENV === "production"
const email = process.env.ADMIN_EMAIL ?? "admin@local.host"
const name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD
if (isProduction && !password) {
throw new Error("ADMIN_PASSWORD is required to bootstrap an admin user")
}
return {
email,
name,
password: password ?? "admin",
}
}
export async function bootstrapAdmin(client: typeof prisma) {
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false"
if (!enabled) return
const admin = getBootstrapAdminInput()
const email = normalizeEmail(admin.email)
const { firstName, lastName } = splitName(admin.name)
const existingUser = await client.user.findUnique({
where: {
emailNormalized: email,
},
select: {
id: true,
passwordHash: true,
activatedAt: true,
person: {
select: {
id: true,
},
},
},
})
const user = existingUser
? await client.user.update({
where: {
id: existingUser.id,
},
data: {
name: admin.name,
email: admin.email,
emailNormalized: email,
role: "ADMIN",
status: UserStatus.ACTIVE,
...(existingUser.passwordHash
? {}
: {
passwordHash: await getPasswordHash(admin.password),
passwordChangedAt: new Date(),
}),
...(existingUser.activatedAt ? {} : { activatedAt: new Date() }),
},
select: {
id: true,
person: {
select: {
id: true,
},
},
},
})
: await client.user.create({
data: {
name: admin.name,
email: admin.email,
emailNormalized: email,
role: "ADMIN",
status: UserStatus.ACTIVE,
passwordHash: await getPasswordHash(admin.password),
activatedAt: new Date(),
passwordChangedAt: new Date(),
},
select: {
id: true,
person: {
select: {
id: true,
},
},
},
})
if (!user.person) {
await client.person.upsert({
where: {
userId: user.id,
},
update: {
firstName,
lastName,
email: admin.email,
},
create: {
firstName,
lastName,
email: admin.email,
user: {
connect: {
id: user.id,
},
},
},
})
}
}
async function main() {
try {
await bootstrapAdmin(prisma)
} finally {
await prisma.$disconnect()
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}
@@ -1,754 +0,0 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'MANAGER', 'STAFF', 'VIEWER');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'DISABLED');
-- CreateEnum
CREATE TYPE "PersonDepartment" AS ENUM ('IT', 'ENGINEERING', 'LOGISTICS', 'TRAFFIC', 'DRIVER', 'ADMINISTRATION', 'SALES', 'OTHER');
-- CreateEnum
CREATE TYPE "ItemTrackingType" AS ENUM ('QUANTITY', 'SERIALIZED');
-- CreateEnum
CREATE TYPE "ItemStatus" AS ENUM ('ACTIVE', 'DISCONTINUED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "AssetStatus" AS ENUM ('AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'BROKEN', 'LOST', 'STOLEN', 'DISPOSED', 'RETIRED');
-- CreateEnum
CREATE TYPE "AssignmentStatus" AS ENUM ('OPEN', 'PARTIALLY_RETURNED', 'RETURNED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "InventoryMovementType" AS ENUM ('RECEIPT', 'ISSUE', 'ASSIGNMENT', 'RETURN', 'ADJUSTMENT', 'STATUS_CHANGE', 'DISPOSAL', 'INITIAL_LOAD');
-- CreateEnum
CREATE TYPE "InventoryMovementReason" AS ENUM ('PURCHASE', 'MANUAL_ENTRY', 'EMPLOYEE_ASSIGNMENT', 'EMPLOYEE_RETURN', 'INVENTORY_CORRECTION', 'DAMAGE', 'REPAIR', 'REPAIR_RETURN', 'LOSS', 'THEFT', 'DISPOSAL', 'INITIAL_LOAD', 'OTHER');
-- CreateEnum
CREATE TYPE "StockAlertStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED');
-- CreateEnum
CREATE TYPE "StockAlertTrigger" AS ENUM ('BELOW_MINIMUM', 'OUT_OF_STOCK');
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailNormalized" TEXT NOT NULL,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'STAFF',
"status" "UserStatus" NOT NULL DEFAULT 'INVITED',
"deletedAt" TIMESTAMP(3),
"invitedAt" TIMESTAMP(3),
"activatedAt" TIMESTAMP(3),
"passwordChangedAt" TIMESTAMP(3),
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserInvitation" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"tokenHash" TEXT NOT NULL,
"invitedById" UUID NOT NULL,
"email" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Person" (
"id" UUID NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"department" "PersonDepartment",
"email" TEXT,
"phone" TEXT,
"userId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Item" (
"id" UUID NOT NULL,
"sku" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"trackingType" "ItemTrackingType" NOT NULL,
"status" "ItemStatus" NOT NULL DEFAULT 'ACTIVE',
"categoryId" UUID NOT NULL,
"stock" INTEGER NOT NULL DEFAULT 0,
"minStock" INTEGER,
"targetStock" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Asset" (
"id" UUID NOT NULL,
"assetTag" TEXT,
"serialNumber" TEXT NOT NULL,
"itemId" UUID NOT NULL,
"status" "AssetStatus" NOT NULL DEFAULT 'AVAILABLE',
"manufacturer" TEXT,
"model" TEXT,
"deliveryNote" TEXT,
"invoiceNumber" TEXT,
"purchaseDate" TIMESTAMP(3),
"purchasePrice" DECIMAL(12,2),
"warrantyEndsAt" TIMESTAMP(3),
"notes" TEXT,
"retiredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Assignment" (
"id" UUID NOT NULL,
"personId" UUID NOT NULL,
"status" "AssignmentStatus" NOT NULL DEFAULT 'OPEN',
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dueAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"notes" TEXT,
"createdById" UUID NOT NULL,
"closedById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssignmentStockLine" (
"id" UUID NOT NULL,
"assignmentId" UUID NOT NULL,
"itemId" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"returnedQuantity" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssignmentStockLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssignmentStockReturn" (
"id" UUID NOT NULL,
"assignmentLineId" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"returnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"receivedById" UUID NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssignmentStockReturn_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssignmentAssetLine" (
"id" UUID NOT NULL,
"assignmentId" UUID NOT NULL,
"assetId" UUID NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returnedAt" TIMESTAMP(3),
"returnedById" UUID,
"returnStatus" "AssetStatus",
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssignmentAssetLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InventoryMovement" (
"id" UUID NOT NULL,
"type" "InventoryMovementType" NOT NULL,
"reason" "InventoryMovementReason" NOT NULL,
"assignmentId" UUID,
"reference" TEXT,
"details" TEXT,
"notes" TEXT,
"performedById" UUID NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InventoryMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovementLine" (
"id" UUID NOT NULL,
"movementId" UUID NOT NULL,
"itemId" UUID NOT NULL,
"stockDelta" INTEGER NOT NULL,
"previousStock" INTEGER NOT NULL,
"newStock" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovementLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssetMovementLine" (
"id" UUID NOT NULL,
"movementId" UUID NOT NULL,
"assetId" UUID NOT NULL,
"previousStatus" "AssetStatus",
"newStatus" "AssetStatus" NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetMovementLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockAlert" (
"id" UUID NOT NULL,
"itemId" UUID NOT NULL,
"trigger" "StockAlertTrigger" NOT NULL,
"status" "StockAlertStatus" NOT NULL DEFAULT 'OPEN',
"availableStock" INTEGER NOT NULL,
"minimumStock" INTEGER NOT NULL,
"suggestedPurchase" INTEGER,
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledgedAt" TIMESTAMP(3),
"acknowledgedById" UUID,
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StockAlert_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_emailNormalized_key" ON "User"("emailNormalized");
-- CreateIndex
CREATE INDEX "User_status_idx" ON "User"("status");
-- CreateIndex
CREATE INDEX "User_deletedAt_idx" ON "User"("deletedAt");
-- CreateIndex
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_tokenHash_key" ON "UserInvitation"("tokenHash");
-- CreateIndex
CREATE INDEX "UserInvitation_userId_idx" ON "UserInvitation"("userId");
-- CreateIndex
CREATE INDEX "UserInvitation_expiresAt_idx" ON "UserInvitation"("expiresAt");
-- CreateIndex
CREATE INDEX "UserInvitation_acceptedAt_idx" ON "UserInvitation"("acceptedAt");
-- CreateIndex
CREATE INDEX "UserInvitation_revokedAt_idx" ON "UserInvitation"("revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Person_userId_key" ON "Person"("userId");
-- CreateIndex
CREATE INDEX "Person_lastName_firstName_idx" ON "Person"("lastName", "firstName");
-- CreateIndex
CREATE INDEX "Person_department_deletedAt_idx" ON "Person"("department", "deletedAt");
-- CreateIndex
CREATE INDEX "Person_deletedAt_idx" ON "Person"("deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE INDEX "Category_deletedAt_idx" ON "Category"("deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Item_sku_key" ON "Item"("sku");
-- CreateIndex
CREATE INDEX "Item_categoryId_status_idx" ON "Item"("categoryId", "status");
-- CreateIndex
CREATE INDEX "Item_trackingType_status_idx" ON "Item"("trackingType", "status");
-- CreateIndex
CREATE INDEX "Item_name_idx" ON "Item"("name");
-- CreateIndex
CREATE INDEX "Item_deletedAt_idx" ON "Item"("deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Asset_assetTag_key" ON "Asset"("assetTag");
-- CreateIndex
CREATE UNIQUE INDEX "Asset_serialNumber_key" ON "Asset"("serialNumber");
-- CreateIndex
CREATE INDEX "Asset_itemId_status_idx" ON "Asset"("itemId", "status");
-- CreateIndex
CREATE INDEX "Asset_status_idx" ON "Asset"("status");
-- CreateIndex
CREATE INDEX "Asset_createdAt_idx" ON "Asset"("createdAt");
-- CreateIndex
CREATE INDEX "Asset_deletedAt_idx" ON "Asset"("deletedAt");
-- CreateIndex
CREATE INDEX "Assignment_personId_status_idx" ON "Assignment"("personId", "status");
-- CreateIndex
CREATE INDEX "Assignment_personId_assignedAt_idx" ON "Assignment"("personId", "assignedAt");
-- CreateIndex
CREATE INDEX "Assignment_status_assignedAt_idx" ON "Assignment"("status", "assignedAt");
-- CreateIndex
CREATE INDEX "Assignment_dueAt_idx" ON "Assignment"("dueAt");
-- CreateIndex
CREATE INDEX "Assignment_createdById_createdAt_idx" ON "Assignment"("createdById", "createdAt");
-- CreateIndex
CREATE INDEX "AssignmentStockLine_assignmentId_idx" ON "AssignmentStockLine"("assignmentId");
-- CreateIndex
CREATE INDEX "AssignmentStockLine_itemId_createdAt_idx" ON "AssignmentStockLine"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "AssignmentStockReturn_assignmentLineId_returnedAt_idx" ON "AssignmentStockReturn"("assignmentLineId", "returnedAt");
-- CreateIndex
CREATE INDEX "AssignmentStockReturn_receivedById_returnedAt_idx" ON "AssignmentStockReturn"("receivedById", "returnedAt");
-- CreateIndex
CREATE INDEX "AssignmentAssetLine_assignmentId_idx" ON "AssignmentAssetLine"("assignmentId");
-- CreateIndex
CREATE INDEX "AssignmentAssetLine_assetId_assignedAt_idx" ON "AssignmentAssetLine"("assetId", "assignedAt");
-- CreateIndex
CREATE INDEX "AssignmentAssetLine_returnedAt_idx" ON "AssignmentAssetLine"("returnedAt");
-- CreateIndex
CREATE INDEX "InventoryMovement_type_occurredAt_idx" ON "InventoryMovement"("type", "occurredAt");
-- CreateIndex
CREATE INDEX "InventoryMovement_reason_occurredAt_idx" ON "InventoryMovement"("reason", "occurredAt");
-- CreateIndex
CREATE INDEX "InventoryMovement_assignmentId_idx" ON "InventoryMovement"("assignmentId");
-- CreateIndex
CREATE INDEX "InventoryMovement_performedById_occurredAt_idx" ON "InventoryMovement"("performedById", "occurredAt");
-- CreateIndex
CREATE INDEX "InventoryMovement_occurredAt_idx" ON "InventoryMovement"("occurredAt");
-- CreateIndex
CREATE INDEX "StockMovementLine_movementId_idx" ON "StockMovementLine"("movementId");
-- CreateIndex
CREATE INDEX "StockMovementLine_itemId_createdAt_idx" ON "StockMovementLine"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "AssetMovementLine_assetId_createdAt_idx" ON "AssetMovementLine"("assetId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "AssetMovementLine_movementId_assetId_key" ON "AssetMovementLine"("movementId", "assetId");
-- CreateIndex
CREATE INDEX "StockAlert_itemId_status_idx" ON "StockAlert"("itemId", "status");
-- CreateIndex
CREATE INDEX "StockAlert_status_triggeredAt_idx" ON "StockAlert"("status", "triggeredAt");
-- CreateIndex
CREATE INDEX "StockAlert_trigger_triggeredAt_idx" ON "StockAlert"("trigger", "triggeredAt");
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentStockLine" ADD CONSTRAINT "AssignmentStockLine_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentStockLine" ADD CONSTRAINT "AssignmentStockLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentStockReturn" ADD CONSTRAINT "AssignmentStockReturn_assignmentLineId_fkey" FOREIGN KEY ("assignmentLineId") REFERENCES "AssignmentStockLine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentStockReturn" ADD CONSTRAINT "AssignmentStockReturn_receivedById_fkey" FOREIGN KEY ("receivedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_returnedById_fkey" FOREIGN KEY ("returnedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryMovement" ADD CONSTRAINT "InventoryMovement_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryMovement" ADD CONSTRAINT "InventoryMovement_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovementLine" ADD CONSTRAINT "StockMovementLine_movementId_fkey" FOREIGN KEY ("movementId") REFERENCES "InventoryMovement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovementLine" ADD CONSTRAINT "StockMovementLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetMovementLine" ADD CONSTRAINT "AssetMovementLine_movementId_fkey" FOREIGN KEY ("movementId") REFERENCES "InventoryMovement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetMovementLine" ADD CONSTRAINT "AssetMovementLine_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAlert" ADD CONSTRAINT "StockAlert_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAlert" ADD CONSTRAINT "StockAlert_acknowledgedById_fkey" FOREIGN KEY ("acknowledgedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- =====================================================
-- USER INVITATION / ACTIVATION
-- =====================================================
ALTER TABLE "User"
ADD CONSTRAINT "User_invited_without_password"
CHECK (
"status" <> 'INVITED'
OR "passwordHash" IS NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_active_requires_password"
CHECK (
"status" <> 'ACTIVE'
OR "passwordHash" IS NOT NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_active_requires_activation_date"
CHECK (
"status" <> 'ACTIVE'
OR "activatedAt" IS NOT NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_activation_date_after_invitation"
CHECK (
"activatedAt" IS NULL
OR "invitedAt" IS NULL
OR "activatedAt" >= "invitedAt"
);
ALTER TABLE "User"
ADD CONSTRAINT "User_password_changed_after_invitation"
CHECK (
"passwordChangedAt" IS NULL
OR "invitedAt" IS NULL
OR "passwordChangedAt" >= "invitedAt"
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_expiry_after_creation"
CHECK ("expiresAt" > "createdAt");
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_accepted_or_revoked"
CHECK (
"acceptedAt" IS NULL
OR "revokedAt" IS NULL
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_accepted_after_creation"
CHECK (
"acceptedAt" IS NULL
OR "acceptedAt" >= "createdAt"
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_revoked_after_creation"
CHECK (
"revokedAt" IS NULL
OR "revokedAt" >= "createdAt"
);
CREATE UNIQUE INDEX "UserInvitation_active_user_key"
ON "UserInvitation" ("userId")
WHERE "acceptedAt" IS NULL
AND "revokedAt" IS NULL;
-- =====================================================
-- ITEM STOCK
-- =====================================================
ALTER TABLE "Item"
ADD CONSTRAINT "Item_stock_non_negative"
CHECK ("stock" >= 0);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_min_stock_non_negative"
CHECK (
"minStock" IS NULL
OR "minStock" >= 0
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_target_stock_non_negative"
CHECK (
"targetStock" IS NULL
OR "targetStock" >= 0
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_target_not_below_minimum"
CHECK (
"minStock" IS NULL
OR "targetStock" IS NULL
OR "targetStock" >= "minStock"
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_serialized_stock_zero"
CHECK (
"trackingType" <> 'SERIALIZED'
OR "stock" = 0
);
-- =====================================================
-- ASSET DATA
-- =====================================================
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_purchase_price_non_negative"
CHECK (
"purchasePrice" IS NULL
OR "purchasePrice" >= 0
);
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_warranty_date_valid"
CHECK (
"warrantyEndsAt" IS NULL
OR "purchaseDate" IS NULL
OR "warrantyEndsAt" >= "purchaseDate"
);
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_retired_date_valid"
CHECK (
"retiredAt" IS NULL
OR "retiredAt" >= "createdAt"
);
-- =====================================================
-- ASSIGNMENTS
-- =====================================================
ALTER TABLE "Assignment"
ADD CONSTRAINT "Assignment_due_date_valid"
CHECK (
"dueAt" IS NULL
OR "dueAt" >= "assignedAt"
);
ALTER TABLE "Assignment"
ADD CONSTRAINT "Assignment_closed_date_valid"
CHECK (
"closedAt" IS NULL
OR "closedAt" >= "assignedAt"
);
-- =====================================================
-- QUANTITY ASSIGNMENTS
-- =====================================================
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_returned_non_negative"
CHECK ("returnedQuantity" >= 0);
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_returned_not_greater"
CHECK ("returnedQuantity" <= "quantity");
ALTER TABLE "AssignmentStockReturn"
ADD CONSTRAINT "AssignmentStockReturn_quantity_positive"
CHECK ("quantity" > 0);
-- =====================================================
-- SERIALIZED ASSET ASSIGNMENTS
-- =====================================================
ALTER TABLE "AssignmentAssetLine"
ADD CONSTRAINT "AssignmentAssetLine_return_date_valid"
CHECK (
"returnedAt" IS NULL
OR "returnedAt" >= "assignedAt"
);
ALTER TABLE "AssignmentAssetLine"
ADD CONSTRAINT "AssignmentAssetLine_return_data_consistent"
CHECK (
(
"returnedAt" IS NULL
AND "returnedById" IS NULL
AND "returnStatus" IS NULL
)
OR
(
"returnedAt" IS NOT NULL
AND "returnedById" IS NOT NULL
AND "returnStatus" IS NOT NULL
)
);
CREATE UNIQUE INDEX "AssignmentAssetLine_active_asset_key"
ON "AssignmentAssetLine" ("assetId")
WHERE "returnedAt" IS NULL;
-- =====================================================
-- STOCK MOVEMENTS
-- =====================================================
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_stock_consistency"
CHECK (
"newStock" = "previousStock" + "stockDelta"
);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_previous_stock_non_negative"
CHECK ("previousStock" >= 0);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_new_stock_non_negative"
CHECK ("newStock" >= 0);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_delta_not_zero"
CHECK ("stockDelta" <> 0);
-- =====================================================
-- STOCK ALERTS
-- =====================================================
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_available_stock_non_negative"
CHECK ("availableStock" >= 0);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_minimum_stock_non_negative"
CHECK ("minimumStock" >= 0);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_suggested_purchase_non_negative"
CHECK (
"suggestedPurchase" IS NULL
OR "suggestedPurchase" >= 0
);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_acknowledgement_consistent"
CHECK (
(
"acknowledgedAt" IS NULL
AND "acknowledgedById" IS NULL
)
OR
(
"acknowledgedAt" IS NOT NULL
AND "acknowledgedById" IS NOT NULL
)
);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_resolution_date_valid"
CHECK (
"resolvedAt" IS NULL
OR "resolvedAt" >= "triggeredAt"
);
CREATE UNIQUE INDEX "StockAlert_active_item_trigger_key"
ON "StockAlert" ("itemId", "trigger")
WHERE "status" IN ('OPEN', 'ACKNOWLEDGED');
@@ -1,24 +0,0 @@
-- CreateTable
CREATE TABLE "Team" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "team_name_lower_unique" ON "Team" (lower("name"));
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "teamId" UUID;
-- CreateIndex
CREATE INDEX "Person_teamId_deletedAt_idx" ON "Person"("teamId", "deletedAt");
-- CreateIndex
CREATE INDEX "Person_teamId_idx" ON "Person"("teamId");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,41 +0,0 @@
BEGIN;
-- Seed legacy teams from the old PersonDepartment enum English display names.
INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt")
VALUES
(gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (lower("name")) DO NOTHING;
-- Backfill Person.teamId from the legacy Person.department enum values.
UPDATE "Person"
SET "teamId" = (
SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department"
WHEN 'IT' THEN 'IT'
WHEN 'ENGINEERING' THEN 'Engineering'
WHEN 'LOGISTICS' THEN 'Logistics'
WHEN 'TRAFFIC' THEN 'Traffic'
WHEN 'DRIVER' THEN 'Driver'
WHEN 'ADMINISTRATION' THEN 'Administration'
WHEN 'SALES' THEN 'Sales'
WHEN 'OTHER' THEN 'Other'
END)
)
WHERE "department" IS NOT NULL;
-- Drop the legacy department index.
DROP INDEX "Person_department_deletedAt_idx";
-- Drop the legacy department column.
ALTER TABLE "Person" DROP COLUMN "department";
-- Drop the legacy enum type.
DROP TYPE "PersonDepartment";
COMMIT;
-580
View File
@@ -1,580 +0,0 @@
// This is your Prisma schema file,
// learn more about the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
}
// ======================================================
// USERS
// ======================================================
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
enum UserStatus {
INVITED
ACTIVE
SUSPENDED
DISABLED
}
model User {
id String @id @default(uuid(7)) @db.Uuid
name String
email String
emailNormalized String @unique
/**
* Nulo mientras el usuario no haya aceptado la invitación.
*/
passwordHash String?
role UserRole @default(STAFF)
status UserStatus @default(INVITED)
deletedAt DateTime?
invitedAt DateTime?
activatedAt DateTime?
passwordChangedAt DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
person Person?
createdAssignments Assignment[] @relation("AssignmentCreatedBy")
closedAssignments Assignment[] @relation("AssignmentClosedBy")
receivedStockReturns AssignmentStockReturn[]
receivedAssetReturns AssignmentAssetLine[] @relation("AssetReturnedBy")
movements InventoryMovement[]
acknowledgedStockAlerts StockAlert[] @relation("StockAlertAcknowledgedBy")
sentInvitations UserInvitation[] @relation("UserInvitationInvitedBy")
invitations UserInvitation[]
@@index([status])
@@index([deletedAt])
@@index([createdAt])
}
model UserInvitation {
id String @id @default(uuid(7)) @db.Uuid
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
/**
* Hash del token de invitación.
* Nunca guardar el token plano.
*/
tokenHash String @unique
invitedById String @db.Uuid
invitedBy User @relation("UserInvitationInvitedBy", fields: [invitedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
email String
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([acceptedAt])
@@index([revokedAt])
}
// ======================================================
// PEOPLE
// ======================================================
model Person {
id String @id @default(uuid(7)) @db.Uuid
firstName String
lastName String
email String?
phone String?
teamId String? @db.Uuid
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String? @unique @db.Uuid
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignments Assignment[]
@@index([lastName, firstName])
@@index([teamId, deletedAt])
@@index([teamId])
@@index([deletedAt])
}
model Team {
id String @id @default(uuid(7)) @db.Uuid
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
people Person[]
}
// ======================================================
// CATALOG
// ======================================================
enum ItemTrackingType {
QUANTITY
SERIALIZED
}
enum ItemStatus {
ACTIVE
DISCONTINUED
ARCHIVED
}
model Category {
id String @id @default(uuid(7)) @db.Uuid
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
items Item[]
@@index([deletedAt])
}
model Item {
id String @id @default(uuid(7)) @db.Uuid
sku String @unique
name String
description String?
trackingType ItemTrackingType
status ItemStatus @default(ACTIVE)
categoryId String @db.Uuid
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: Cascade)
/**
* Solo se utiliza para artículos QUANTITY.
* Para artículos SERIALIZED, las existencias se obtienen
* contando los activos AVAILABLE.
*/
stock Int @default(0)
/**
* Umbral de alerta.
* QUANTITY:
* Se compara contra Item.stock.
* SERIALIZED:
* Se compara contra número de Asset AVAILABLE.
*/
minStock Int?
/**
* Nivel deseado tras reposición.
* Compra sugerida:
* targetStock - stock disponible.
*/
targetStock Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assets Asset[]
assignmentStockLines AssignmentStockLine[]
stockMovementLines StockMovementLine[]
stockAlerts StockAlert[]
@@index([categoryId, status])
@@index([trackingType, status])
@@index([name])
@@index([deletedAt])
}
// ======================================================
// SERIALIZED ASSETS
// ======================================================
enum AssetStatus {
AVAILABLE
ASSIGNED
IN_REPAIR
BROKEN
LOST
STOLEN
DISPOSED
RETIRED
}
model Asset {
id String @id @default(uuid(7)) @db.Uuid
/**
* Identificador interno visible.
* Ejemplos:
* IT-000001
* LAP-000042
* MON-000117
*/
assetTag String? @unique
/**
* Número de serie del fabricante.
* Puede ser nulo.
*/
serialNumber String @unique
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
status AssetStatus @default(AVAILABLE)
manufacturer String?
model String?
deliveryNote String?
invoiceNumber String?
purchaseDate DateTime?
purchasePrice Decimal? @db.Decimal(12, 2)
warrantyEndsAt DateTime?
notes String?
retiredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignmentLines AssignmentAssetLine[]
movementLines AssetMovementLine[]
@@index([itemId, status])
@@index([status])
@@index([createdAt])
@@index([deletedAt])
}
// ======================================================
// ASSIGNMENTS
// ======================================================
enum AssignmentStatus {
OPEN
PARTIALLY_RETURNED
RETURNED
CANCELLED
}
model Assignment {
id String @id @default(uuid(7)) @db.Uuid
personId String @db.Uuid
person Person @relation(fields: [personId], references: [id], onDelete: Restrict, onUpdate: Cascade)
status AssignmentStatus @default(OPEN)
assignedAt DateTime @default(now())
dueAt DateTime?
closedAt DateTime?
notes String?
createdById String @db.Uuid
createdBy User @relation("AssignmentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict, onUpdate: Cascade)
closedById String? @db.Uuid
closedBy User? @relation("AssignmentClosedBy", fields: [closedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stockLines AssignmentStockLine[]
assetLines AssignmentAssetLine[]
movements InventoryMovement[]
@@index([personId, status])
@@index([personId, assignedAt])
@@index([status, assignedAt])
@@index([dueAt])
@@index([createdById, createdAt])
}
// ======================================================
// QUANTITY ASSIGNMENTS
// ======================================================
model AssignmentStockLine {
id String @id @default(uuid(7)) @db.Uuid
assignmentId String @db.Uuid
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
quantity Int
returnedQuantity Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
returns AssignmentStockReturn[]
@@index([assignmentId])
@@index([itemId, createdAt])
}
model AssignmentStockReturn {
id String @id @default(uuid(7)) @db.Uuid
assignmentLineId String @db.Uuid
assignmentLine AssignmentStockLine @relation(fields: [assignmentLineId], references: [id], onDelete: Restrict, onUpdate: Cascade)
quantity Int
returnedAt DateTime @default(now())
receivedById String @db.Uuid
receivedBy User @relation(fields: [receivedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
notes String?
createdAt DateTime @default(now())
@@index([assignmentLineId, returnedAt])
@@index([receivedById, returnedAt])
}
// ======================================================
// SERIALIZED ASSET ASSIGNMENTS
// ======================================================
model AssignmentAssetLine {
id String @id @default(uuid(7)) @db.Uuid
assignmentId String @db.Uuid
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Restrict, onUpdate: Cascade)
assignedAt DateTime @default(now())
returnedAt DateTime?
returnedById String? @db.Uuid
returnedBy User? @relation("AssetReturnedBy", fields: [returnedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
returnStatus AssetStatus?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/**
* La unicidad de asignación activa se protege
* mediante índice único parcial en PostgreSQL.
*/
@@index([assignmentId])
@@index([assetId, assignedAt])
@@index([returnedAt])
}
// ======================================================
// INVENTORY MOVEMENTS
// ======================================================
enum InventoryMovementType {
RECEIPT
ISSUE
ASSIGNMENT
RETURN
ADJUSTMENT
STATUS_CHANGE
DISPOSAL
INITIAL_LOAD
}
enum InventoryMovementReason {
PURCHASE
MANUAL_ENTRY
EMPLOYEE_ASSIGNMENT
EMPLOYEE_RETURN
INVENTORY_CORRECTION
DAMAGE
REPAIR
REPAIR_RETURN
LOSS
THEFT
DISPOSAL
INITIAL_LOAD
OTHER
}
model InventoryMovement {
id String @id @default(uuid(7)) @db.Uuid
type InventoryMovementType
reason InventoryMovementReason
assignmentId String? @db.Uuid
assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
reference String?
details String?
notes String?
performedById String @db.Uuid
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
stockLines StockMovementLine[]
assetLines AssetMovementLine[]
@@index([type, occurredAt])
@@index([reason, occurredAt])
@@index([assignmentId])
@@index([performedById, occurredAt])
@@index([occurredAt])
}
// ======================================================
// QUANTITY MOVEMENTS
// ======================================================
model StockMovementLine {
id String @id @default(uuid(7)) @db.Uuid
movementId String @db.Uuid
movement InventoryMovement @relation(fields: [movementId], references: [id], onDelete: Cascade, onUpdate: Cascade)
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
/**
* Positivo: entrada/devolución/ajuste positivo.
* Negativo: salida/asignación/ajuste negativo.
*/
stockDelta Int
previousStock Int
newStock Int
createdAt DateTime @default(now())
@@index([movementId])
@@index([itemId, createdAt])
}
// ======================================================
// SERIALIZED ASSET MOVEMENTS
// ======================================================
model AssetMovementLine {
id String @id @default(uuid(7)) @db.Uuid
movementId String @db.Uuid
movement InventoryMovement @relation(fields: [movementId], references: [id], onDelete: Cascade, onUpdate: Cascade)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Restrict, onUpdate: Cascade)
previousStatus AssetStatus?
newStatus AssetStatus
notes String?
createdAt DateTime @default(now())
@@unique([movementId, assetId])
@@index([assetId, createdAt])
}
// ======================================================
// STOCK ALERTS
// ======================================================
enum StockAlertStatus {
OPEN
ACKNOWLEDGED
RESOLVED
}
enum StockAlertTrigger {
BELOW_MINIMUM
OUT_OF_STOCK
}
model StockAlert {
id String @id @default(uuid(7)) @db.Uuid
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
trigger StockAlertTrigger
status StockAlertStatus @default(OPEN)
availableStock Int
minimumStock Int
suggestedPurchase Int?
triggeredAt DateTime @default(now())
acknowledgedAt DateTime?
acknowledgedById String? @db.Uuid
acknowledgedBy User? @relation("StockAlertAcknowledgedBy", fields: [acknowledgedById], references: [id], onDelete: SetNull, onUpdate: Cascade)
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([itemId, status])
@@index([status, triggeredAt])
@@index([trigger, triggeredAt])
}
-17
View File
@@ -1,17 +0,0 @@
import prisma from "../src/lib/prisma"
import { bootstrapAdmin } from "./bootstrap-admin"
async function main() {
await bootstrapAdmin(prisma)
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
-109
View File
@@ -1,109 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { localizeAssetFieldErrors } from "@/actions/asset.messages"
import { getI18n } from "@/i18n/server"
import {
buildCreateAssetSchema,
buildUpdateAssetSchema,
type CreateAssetFormType,
type UpdateAssetFormType,
} from "@/schemas/asset.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
createAssetUseCase,
updateAssetUseCase,
} from "@/use-cases/asset.use-cases"
export async function createAssetAction(formData: CreateAssetFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
const validatedFields = buildCreateAssetSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await createAssetUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return {
...result,
errors: localizeAssetFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/inventory/assets")
revalidatePath("/inventory/items")
revalidatePath("/assignments")
revalidatePath("/movements")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.createFailure,
}
}
}
export async function updateAssetAction(formData: UpdateAssetFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
const validatedFields = buildUpdateAssetSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await updateAssetUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return {
...result,
errors: localizeAssetFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/inventory/assets")
revalidatePath("/inventory/items")
revalidatePath("/assignments")
revalidatePath("/movements")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.updateFailure,
}
}
}
-40
View File
@@ -1,40 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type AssetActionCopy = Dictionary["inventory"]["assets"]["actions"]
type FieldErrors = Record<string, string[]>
const assetErrorMessageKeys = {
"Item not found": "itemNotFound",
"Asset not found": "notFound",
"This serial number already exists": "duplicateSerialNumber",
"Assignment already returned": "assignmentAlreadyReturned",
"Previous item not found for available asset": "previousItemNotFound",
"Item does not have enough stock": "insufficientStock",
} as const satisfies Record<string, keyof AssetActionCopy>
function isAssetErrorMessage(
message: string,
): message is keyof typeof assetErrorMessageKeys {
return message in assetErrorMessageKeys
}
function localizeAssetMessage(message: string, copy: AssetActionCopy): string {
if (!isAssetErrorMessage(message)) return message
return copy[assetErrorMessageKeys[message]]
}
export function localizeAssetFieldErrors(
errors: FieldErrors | undefined,
copy: AssetActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeAssetMessage(message, copy)),
]),
)
}
-177
View File
@@ -1,177 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages"
import { getI18n } from "@/i18n/server"
import {
buildCreateAssignmentSchema,
buildReturnAssignmentSchema,
buildUpdateAssignmentSchema,
type CreateAssignmentFormType,
type ReturnAssignmentFormType,
type UpdateAssignmentFormType,
} from "@/schemas/assignment.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
createAssignmentUseCase,
returnAssignmentUseCase,
updateAssignmentUseCase,
} from "@/use-cases/assignment.use-cases"
export async function createAssignment(formData: CreateAssignmentFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = buildCreateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, assetId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const normalizedQuantity = assetId ? 1 : quantity
const result = await createAssignmentUseCase({
...validatedFields.data,
quantity: normalizedQuantity,
lines: [
{
itemId,
quantity: normalizedQuantity,
notes,
},
],
actorId: createdBy,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.createFailure,
}
}
}
export async function updateAssignment(formData: UpdateAssignmentFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = buildUpdateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await updateAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId,
quantity,
notes,
},
],
actorId: createdBy,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.updateFailure,
}
}
}
type ReturnAssignmentActionResult =
| { success: true; message: string }
| { success: false; errors?: Record<string, string[]>; message?: string }
export async function returnAssignment(
formData: ReturnAssignmentFormType,
): Promise<ReturnAssignmentActionResult> {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const userId = await getAuthenticatedUserId()
const validatedFields = buildReturnAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
const result = await returnAssignmentUseCase({
id: validatedFields.data.id,
actorId: userId,
returns: validatedFields.data.returns,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
message: copy.actions.returnFailure,
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.returnSuccess,
}
}
-44
View File
@@ -1,44 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type AssignmentActionCopy = Dictionary["inventory"]["assignments"]["actions"]
type FieldErrors = Record<string, string[]>
const assignmentErrorMessageKeys = {
"Item not found": "itemNotFound",
"Item does not have enough stock": "itemInsufficientStock",
"Asset not found": "assetNotFound",
"Asset does not belong to item": "assetItemMismatch",
"Assignment not found": "notFound",
"Assignment already returned": "assignmentAlreadyReturned",
"Invalid assignment data": "invalidData",
} as const satisfies Record<string, keyof AssignmentActionCopy>
function isAssignmentErrorMessage(
message: string,
): message is keyof typeof assignmentErrorMessageKeys {
return message in assignmentErrorMessageKeys
}
function localizeAssignmentMessage(
message: string,
copy: AssignmentActionCopy,
): string {
if (!isAssignmentErrorMessage(message)) return message
return copy[assignmentErrorMessageKeys[message]]
}
export function localizeAssignmentFieldErrors(
errors: FieldErrors | undefined,
copy: AssignmentActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeAssignmentMessage(message, copy)),
]),
)
}
-133
View File
@@ -1,133 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateCategorySchema,
buildUpdateCategorySchema,
type CreateCategoryFormType,
type UpdateCategoryFormType,
} from "@/schemas/category.schema"
import {
createCategoryUseCase,
deleteCategoryUseCase,
updateCategoryUseCase,
} from "@/use-cases/category.use-cases"
import { localizeCategoryFieldErrors } from "./category.messages"
export async function createCategoryAction(formData: CreateCategoryFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const validatedFields = buildCreateCategorySchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createCategoryUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/inventory/categories")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.createFailure,
errors: {
name: [copy.actions.duplicateName],
},
}
}
}
export async function updateCategoryAction(formData: UpdateCategoryFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const validatedFields = buildUpdateCategorySchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updateCategoryUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/inventory/categories")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.updateFailure,
}
}
}
export async function deleteCategoryAction(formData: FormData) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const { id } = Object.fromEntries(formData) as { id: string }
try {
const result = await deleteCategoryUseCase(id)
if (!result.success) {
return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
revalidatePath("/inventory/categories")
return {
success: true as const,
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.deleteFailure,
errors: {},
}
}
}
-43
View File
@@ -1,43 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type CategoryActionCopy = Dictionary["inventory"]["categories"]["actions"]
type FieldErrors = Record<string, string[]>
const categoryErrorMessageKeys = {
"Category already exists": "duplicateName",
"Category name is the same as the old one": "unchangedName",
"Category name unchanged": "unchangedName",
"Category not found": "notFound",
"Category has items": "hasItems",
"Cannot delete category with items": "hasItems",
} as const satisfies Record<string, keyof CategoryActionCopy>
function isCategoryErrorMessage(
message: string,
): message is keyof typeof categoryErrorMessageKeys {
return message in categoryErrorMessageKeys
}
function localizeCategoryMessage(
message: string,
copy: CategoryActionCopy,
): string {
if (!isCategoryErrorMessage(message)) return message
return copy[categoryErrorMessageKeys[message]]
}
export function localizeCategoryFieldErrors(
errors: FieldErrors | undefined,
copy: CategoryActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeCategoryMessage(message, copy)),
]),
)
}
-33
View File
@@ -1,33 +0,0 @@
"use server"
import { cookies } from "next/headers"
import {
isLocale,
LOCALE_COOKIE_MAX_AGE_SECONDS,
LOCALE_COOKIE_NAME,
type Locale,
} from "@/i18n/locales"
export type SetLocaleActionResult =
| { success: true; locale: Locale }
| { success: false; error: "UNSUPPORTED_LOCALE" }
export async function setLocaleAction(
requestedLocale: string,
): Promise<SetLocaleActionResult> {
if (!isLocale(requestedLocale)) {
return { success: false, error: "UNSUPPORTED_LOCALE" }
}
const cookieStore = await cookies()
cookieStore.set(LOCALE_COOKIE_NAME, requestedLocale, {
path: "/",
sameSite: "lax",
maxAge: LOCALE_COOKIE_MAX_AGE_SECONDS,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
})
return { success: true, locale: requestedLocale }
}
-135
View File
@@ -1,135 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateItemSchema,
buildUpdateItemSchema,
type CreateItemFormType,
type UpdateItemFormType,
} from "@/schemas/item.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
createItemUseCase,
deleteItemUseCase,
updateItemUseCase,
} from "@/use-cases/item.use-cases"
import { localizeItemFieldErrors } from "./item.messages"
export async function createItemAction(formData: CreateItemFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildCreateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await createItemUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/inventory/items")
revalidatePath("/movements")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
error: copy.actions.createFailure,
}
}
}
export async function updateItemAction(formData: UpdateItemFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildUpdateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await updateItemUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/inventory/items")
revalidatePath("/movements")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
error: copy.actions.updateFailure,
}
}
}
export async function deleteItemAction(formData: FormData) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const { id } = Object.fromEntries(formData) as { id: string }
try {
const result = await deleteItemUseCase(id)
if (!result.success) {
return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
revalidatePath("/inventory/items")
return {
success: true as const,
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.deleteFailure,
errors: {},
}
}
}
-40
View File
@@ -1,40 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type ItemActionCopy = Dictionary["inventory"]["items"]["actions"]
type FieldErrors = Record<string, string[]>
const itemErrorMessageKeys = {
"An item with this name already exists": "duplicateName",
"Item not found": "notFound",
"Item has assets, you cannot delete it": "hasAssets",
"Item has stock, you cannot delete it": "hasStock",
"Invalid stock": "invalidStock",
"Stock cannot be negative": "negativeStock",
} as const satisfies Record<string, keyof ItemActionCopy>
function isItemErrorMessage(
message: string,
): message is keyof typeof itemErrorMessageKeys {
return message in itemErrorMessageKeys
}
function localizeItemMessage(message: string, copy: ItemActionCopy): string {
if (!isItemErrorMessage(message)) return message
return copy[itemErrorMessageKeys[message]]
}
export function localizeItemFieldErrors(
errors: FieldErrors | undefined,
copy: ItemActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeItemMessage(message, copy)),
]),
)
}
-192
View File
@@ -1,192 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreatePersonSchema,
buildUpdatePersonSchema,
type CreatePersonFormType,
type UpdatePersonFormType,
} from "@/schemas/person.schema"
import {
buildUnifiedCreateSchema,
buildUnifiedUpdateSchema,
type UnifiedCreateFormType,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import {
createPersonUseCase,
createPersonUserUseCase,
updatePersonUseCase,
updatePersonUserUseCase,
} from "@/use-cases/person.use-cases"
import { localizePersonFieldErrors } from "./person.messages"
import { localizeUnifiedCreateFieldErrors } from "./user.messages"
const PERSON_USER_PATH = "/people"
export async function createNewPerson(formData: CreatePersonFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const validatedFields = buildCreatePersonSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createPersonUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizePersonFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.createFailure,
}
}
}
export async function createPersonUserAction(formData: UnifiedCreateFormType) {
const { dictionary } = await getI18n()
const userCopy = dictionary.admin.users
const schemaCopy = {
...userCopy.schema,
...dictionary.inventory.people.schema,
}
const validatedFields =
buildUnifiedCreateSchema(schemaCopy).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createPersonUserUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUnifiedCreateFieldErrors(
result.errors,
userCopy.actions,
schemaCopy,
),
message: userCopy.actions.createFailure,
}
}
revalidatePath(PERSON_USER_PATH)
return { success: true, message: userCopy.actions.createSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: userCopy.actions.createFailure }
}
}
export async function updatePerson(formData: UpdatePersonFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const validatedFields = buildUpdatePersonSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updatePersonUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizePersonFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.updateFailure,
}
}
}
export async function updatePersonUserAction(formData: UnifiedUpdateFormType) {
const { dictionary } = await getI18n()
const userCopy = dictionary.admin.users
const personCopy = dictionary.inventory.people
const schemaCopy: UnifiedSchemaCopy = {
...userCopy.schema,
...personCopy.schema,
}
const validatedFields =
buildUnifiedUpdateSchema(schemaCopy).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updatePersonUserUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUnifiedCreateFieldErrors(
result.errors,
userCopy.actions,
schemaCopy,
),
message: personCopy.actions.updateFailure,
}
}
revalidatePath("/people")
return { success: true, message: personCopy.actions.updateSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: personCopy.actions.updateFailure }
}
}
-39
View File
@@ -1,39 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type PersonActionCopy = Dictionary["inventory"]["people"]["actions"]
type FieldErrors = Record<string, string[]>
const personErrorMessageKeys = {
"Email already exists": "duplicateEmail",
"Team not found": "teamNotFound",
} as const satisfies Record<string, keyof PersonActionCopy>
function isPersonErrorMessage(
message: string,
): message is keyof typeof personErrorMessageKeys {
return message in personErrorMessageKeys
}
function localizePersonMessage(
message: string,
copy: PersonActionCopy,
): string {
if (!isPersonErrorMessage(message)) return message
return copy[personErrorMessageKeys[message]]
}
export function localizePersonFieldErrors(
errors: FieldErrors | undefined,
copy: PersonActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizePersonMessage(message, copy)),
]),
)
}
-143
View File
@@ -1,143 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateTeamSchema,
buildUpdateTeamSchema,
type CreateTeamFormType,
type UpdateTeamFormType,
} from "@/schemas/team.schema"
import { getAuthenticatedSession, requireRole } from "@/services/auth.service"
import {
createTeamUseCase,
deleteTeamUseCase,
listTeamsUseCase,
updateTeamUseCase,
} from "@/use-cases/team.use-cases"
import { localizeTeamFieldErrors } from "./team.messages"
export async function createTeamAction(formData: CreateTeamFormType) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const validatedFields = buildCreateTeamSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createTeamUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.createFailure,
errors: {
name: [copy.actions.duplicateName],
},
}
}
}
export async function updateTeamAction(formData: UpdateTeamFormType) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const validatedFields = buildUpdateTeamSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updateTeamUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/people")
return {
success: true,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: copy.actions.updateFailure,
}
}
}
export async function deleteTeamAction(formData: FormData) {
await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
const { id } = Object.fromEntries(formData) as { id: string }
try {
const result = await deleteTeamUseCase(id)
if (!result.success) {
return {
...result,
errors: localizeTeamFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
revalidatePath("/people")
return {
success: true as const,
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.deleteFailure,
errors: {},
}
}
}
export async function listTeamsAction() {
await getAuthenticatedSession()
return listTeamsUseCase()
}
-38
View File
@@ -1,38 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"]
type FieldErrors = Record<string, string[]>
const teamErrorMessageKeys = {
"Team already exists": "duplicateName",
"Team name is the same": "unchangedName",
"Team name unchanged": "unchangedName",
"Team not found": "notFound",
} as const satisfies Record<string, keyof TeamActionCopy>
function isTeamErrorMessage(
message: string,
): message is keyof typeof teamErrorMessageKeys {
return message in teamErrorMessageKeys
}
function localizeTeamMessage(message: string, copy: TeamActionCopy): string {
if (!isTeamErrorMessage(message)) return message
return copy[teamErrorMessageKeys[message]]
}
export function localizeTeamFieldErrors(
errors: FieldErrors | undefined,
copy: TeamActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeTeamMessage(message, copy)),
]),
)
}
-168
View File
@@ -1,168 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateUserSchema,
buildResetUserPasswordSchema,
buildSetUserActiveSchema,
buildUpdateUserSchema,
type CreateUserFormType,
type ResetUserPasswordFormType,
type SetUserActiveFormType,
type UpdateUserFormType,
} from "@/schemas/user.schema"
import { requireRole } from "@/services/auth.service"
import {
createUserUseCase,
resetUserPasswordUseCase,
setUserActiveUseCase,
updateUserUseCase,
} from "@/use-cases/user.use-cases"
import { localizeUserFieldErrors } from "./user.messages"
const USERS_PATH = "/people"
export async function createUserAction(formData: CreateUserFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildCreateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await createUserUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: copy.actions.createSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: copy.actions.createFailure }
}
}
export async function updateUserAction(formData: UpdateUserFormType) {
const session = await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildUpdateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updateUserUseCase({
...validatedFields.data,
actorId: session.user.id,
})
if (!result.success) {
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: copy.actions.updateSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: copy.actions.updateFailure }
}
}
export async function setUserActiveAction(formData: SetUserActiveFormType) {
const session = await requireRole("ADMIN")
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildSetUserActiveSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await setUserActiveUseCase({
...validatedFields.data,
actorId: session.user.id,
})
if (!result.success) {
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.toggleStatusFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: copy.actions.toggleStatusSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: copy.actions.toggleStatusFailure }
}
}
export async function resetUserPasswordAction(
formData: ResetUserPasswordFormType,
) {
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildResetUserPasswordSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await resetUserPasswordUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.resetPasswordFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: copy.actions.resetPasswordSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: copy.actions.resetPasswordFailure }
}
}
-91
View File
@@ -1,91 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { UnifiedSchemaCopy } from "@/schemas/user.schema"
type UserActionCopy = Dictionary["admin"]["users"]["actions"]
type FieldErrors = Record<string, string[]>
const userErrorMessageKeys = {
"Email already exists": "duplicateEmail",
"User not found": "notFound",
"Cannot remove access from the last active administrator": "lastActiveAdmin",
"You cannot remove your own administrator access": "selfAdminAccess",
"You cannot deactivate your own user": "selfDeactivate",
} as const satisfies Record<string, keyof UserActionCopy>
function isUserErrorMessage(
message: string,
): message is keyof typeof userErrorMessageKeys {
return message in userErrorMessageKeys
}
function localizeUserMessage(message: string, copy: UserActionCopy): string {
if (!isUserErrorMessage(message)) return message
return copy[userErrorMessageKeys[message]]
}
export function localizeUserFieldErrors(
errors: FieldErrors | undefined,
copy: UserActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeUserMessage(message, copy)),
]),
)
}
type UnifiedCreateActionCopy = Dictionary["admin"]["users"]["actions"]
const unifiedCreateErrorMessageKeys = {
"Email already exists": "duplicateEmail",
} as const satisfies Record<string, keyof UnifiedCreateActionCopy>
function isUnifiedCreateErrorMessage(
message: string,
): message is keyof typeof unifiedCreateErrorMessageKeys {
return message in unifiedCreateErrorMessageKeys
}
function localizeUnifiedCreateMessage(
message: string,
copy: UnifiedCreateActionCopy,
): string {
if (!isUnifiedCreateErrorMessage(message)) return message
return copy[unifiedCreateErrorMessageKeys[message]]
}
export function localizeUnifiedCreateFieldErrors(
errors: FieldErrors | undefined,
copy: UnifiedCreateActionCopy,
schemaCopy: UnifiedSchemaCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => {
// Schema-level validation messages come from schemaCopy
if (field === "firstName" && message === schemaCopy.firstNameRequired)
return message
if (field === "lastName" && message === schemaCopy.lastNameRequired)
return message
if (field === "teamId" && message === schemaCopy.teamIdInvalid)
return message
if (field === "email" && message === schemaCopy.emailInvalid)
return message
if (field === "password" && message === schemaCopy.passwordMinLength)
return message
// Action-level messages (like "Email already exists") come from action copy
return localizeUnifiedCreateMessage(message, copy)
}),
]),
)
}
+12 -16
View File
@@ -4,16 +4,12 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { signInAction } from "@/actions/auth.actions"
import { Button } from "@/components/ui/button"
import type { Dictionary } from "@/i18n/dictionaries"
import { type SignInFormType, signInSchema } from "@/schemas/auth.schema"
import { signInAction } from "@/lib/actions/auth.actions"
import { SignInFormType, signInSchema } from "@/lib/schemas/auth.schemas"
type SignInFormProps = {
copy: Dictionary["login"]
}
export default function SignInForm({ copy }: SignInFormProps) {
export default function SignInForm() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl")
@@ -22,7 +18,7 @@ export default function SignInForm({ copy }: SignInFormProps) {
const { register, handleSubmit, formState } = useForm<SignInFormType>({
resolver: zodResolver(signInSchema),
defaultValues: {
email: "",
username: "",
password: "",
},
})
@@ -42,19 +38,19 @@ export default function SignInForm({ copy }: SignInFormProps) {
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<label className="flex flex-col gap-1">
{copy.emailLabel}
Username
<input
{...register("email")}
name="email"
{...register("username")}
name="username"
type="text"
className="border-input w-full rounded-md border-2 p-2"
/>
{formState.errors.email && (
<p className="text-error">{formState.errors.email.message}</p>
{formState.errors.username && (
<p className="text-error">{formState.errors.username.message}</p>
)}
</label>
<label className="flex flex-col gap-1">
{copy.passwordLabel}
Password
<input
{...register("password")}
name="password"
@@ -66,7 +62,7 @@ export default function SignInForm({ copy }: SignInFormProps) {
)}
</label>
{error && <p className="text-error">{error}</p>}
<Button type="submit">{copy.submitLabel}</Button>
<Button type="submit">Sign In</Button>
</form>
)
}
+3 -16
View File
@@ -1,8 +1,6 @@
import { redirect } from "next/navigation"
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth"
import SignInForm from "./_components/login-form"
@@ -12,26 +10,15 @@ export default async function LoginPage() {
if (session) redirect("/")
const { dictionary, locale } = await getI18n()
const copy = dictionary.login
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 space-y-3">
<div className="flex justify-end">
<LanguageSwitcher
activeLocale={locale}
copy={dictionary.common.languageSwitcher}
/>
</div>
<div className="w-full max-w-sm">
<Card>
<CardHeader>
<CardTitle>
<h1>{copy.title}</h1>
</CardTitle>
<CardTitle>Sign In</CardTitle>
</CardHeader>
<CardContent>
<SignInForm copy={copy} />
<SignInForm />
</CardContent>
</Card>
</div>
@@ -3,13 +3,11 @@ import Link from "next/link"
export default function Card({
title,
total,
countLabel,
icon,
href,
}: {
title: string
total: number
countLabel: string
icon: React.ReactNode
href: string
}) {
@@ -20,9 +18,7 @@ export default function Card({
<div className="mr-4">{icon}</div>
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">
{countLabel}: {total}
</p>
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
</div>
</div>
</div>
+8 -20
View File
@@ -1,25 +1,21 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { PersonService } from "@/services/person.service"
import { RecipientService } from "@/services/recipient.service"
import Card from "./_components/card"
export default async function Home() {
const { dictionary } = await getI18n()
const copy = dictionary.dashboardHome
const totalItems = await ItemService.findAllItemsCount()
const totalAssets = await AssetService.findAllAssetsCount()
const totalPeople = await PersonService.findAllPeopleCount()
const totalRecipients = await RecipientService.findAllRecipientsCount()
return (
<div className="container mx-auto p-4">
<h1 className="mb-4 text-2xl font-bold">{copy.heading}</h1>
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card
title={copy.cards.items.title}
title="Total Items"
total={totalItems}
countLabel={copy.cards.items.countLabel}
href="/inventory/items"
icon={
<svg
@@ -28,8 +24,6 @@ export default async function Home() {
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="total-items"
>
<path
strokeLinecap="round"
@@ -41,9 +35,8 @@ export default async function Home() {
}
/>
<Card
title={copy.cards.assets.title}
title="Total Assets"
total={totalAssets}
countLabel={copy.cards.assets.countLabel}
href="/inventory/assets"
icon={
<svg
@@ -52,8 +45,6 @@ export default async function Home() {
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="total-assets"
>
<path
strokeLinecap="round"
@@ -65,10 +56,9 @@ export default async function Home() {
}
/>
<Card
title={copy.cards.people.title}
total={totalPeople}
countLabel={copy.cards.people.countLabel}
href="/people"
title="Total Recipients"
total={totalRecipients}
href="/recipients"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -76,8 +66,6 @@ export default async function Home() {
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="total-people"
>
<path
strokeLinecap="round"
@@ -0,0 +1,41 @@
import { UpdateAssignmentFormType } from "@/lib/schemas/assignment.schemas"
import type { Item } from "@/lib/types"
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>
}
let assignmentItem: Item = {} as Item
if (assignment.itemId) {
assignmentItem = (await ItemService.findById(assignment.itemId)) as Item
items.push(assignmentItem)
}
return (
<div>
<AssignmentForm
recipients={recipients}
items={items}
assets={assets}
initialData={assignment as UpdateAssignmentFormType}
/>
</div>
)
}
@@ -1,58 +0,0 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { PersonService } from "@/services/person.service"
import type { Item } from "@/types"
import AssignmentForm from "../../_components/edit.assignment.form"
export default async function EditAssignmentPage({
params,
}: {
params: Promise<{ assignmentId: string }>
}) {
const { assignmentId } = await params
const assignment = await AssignmentService.findById(assignmentId)
const people = await PersonService.findAll()
const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
if (!assignment) {
return <div>{copy.edit.notFound}</div>
}
let assignmentItem: Item = {} as Item
if (assignment.itemId) {
assignmentItem = (await ItemService.findById(assignment.itemId)) as Item
items.push(assignmentItem)
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<AssignmentForm
people={people}
items={items}
assets={assets}
initialData={{
...assignment,
id: assignment.id,
personId: assignment.personId ?? "",
itemId: assignment.itemId ?? undefined,
assetId: assignment.assetId ?? undefined,
quantity: assignment.quantity ?? undefined,
notes: assignment.notes ?? undefined,
assignmentDate: assignment.assignmentDate ?? undefined,
}}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -2,57 +2,39 @@
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 { updateAssignment } from "@/actions/assignment.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildUpdateAssignmentSchema,
type UpdateAssignmentFormType,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Person } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
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 {
people: Person[]
recipients: Recipient[]
items: Item[]
assets: Asset[]
initialData: UpdateAssignmentFormType
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
export default function EditAssignmentForm({
people,
recipients,
items,
assets,
initialData,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const router = useRouter()
const schema = useMemo(
() => buildUpdateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssignmentFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(updateAssignmentSchema),
defaultValues: {
...initialData,
id: initialData.id || undefined,
@@ -69,7 +51,7 @@ export default function EditAssignmentForm({
if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => {
messages.forEach((msg) => void toast.error(msg))
messages.forEach((msg) => toast.error(msg))
},
)
return
@@ -84,29 +66,29 @@ export default function EditAssignmentForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="personId"
{...register("personId")}
id="recipientId"
{...register("recipientId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.personId ? "border-error" : ""
errors.recipientId ? "border-error" : ""
}`}
>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
{recipients.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors.personId && (
<p className="text-error">{errors.personId.message}</p>
{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">
{formCopy.itemLabel}
Item
</label>
<select
id="itemId"
@@ -125,7 +107,7 @@ export default function EditAssignmentForm({
</div>
<div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
{formCopy.assetLabel}
Asset
</label>
<select
id="assetId"
@@ -134,7 +116,7 @@ export default function EditAssignmentForm({
errors.assetId ? "border-error" : ""
}`}
>
<option value="">{formCopy.assetPlaceholder}</option>
<option value="">Select an asset</option>
{itemId
? assets.map((asset) => (
<option key={asset.id} value={asset.id}>
@@ -149,7 +131,7 @@ export default function EditAssignmentForm({
</div>
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
{formCopy.quantityLabel}
Quantity
</label>
<input
type="number"
@@ -157,7 +139,6 @@ export default function EditAssignmentForm({
disabled={!itemId || assets.length > 0}
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
placeholder={formCopy.quantityPlaceholder}
defaultValue={1}
{...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${
@@ -169,12 +150,11 @@ export default function EditAssignmentForm({
)}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (assets.length > 0 && !assetId)}
>
{formCopy.updateSubmit}
Update Assignment
</SubmitButton>
</form>
)
@@ -1,63 +1,40 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createAssignment } from "@/actions/assignment.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildCreateAssignmentSchema,
type CreateAssignmentFormType,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Person } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
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 {
people: Person[]
recipients: Recipient[]
items: Item[]
assets: Asset[]
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
export default function CreateAssignmentForm({
people,
recipients,
items,
assets,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const searchParams = useSearchParams()
const personId = searchParams.get("personId")
const router = useRouter()
const schema = useMemo(
() => buildCreateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssignmentFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(createAssignmentSchema),
mode: "onSubmit",
defaultValues: {
personId: personId ?? "",
quantity: 1,
},
})
const itemId = watch("itemId")
@@ -68,15 +45,12 @@ export default function CreateAssignmentForm({
}, [assets, itemId])
const onSubmit = async (formData: CreateAssignmentFormType) => {
const response = await createAssignment({
...formData,
quantity: itemAssets.length > 0 ? 1 : formData.quantity,
})
const response = await createAssignment(formData)
if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => {
messages.forEach((msg) => void toast.error(msg))
messages.forEach((msg) => toast.error(msg))
},
)
return
@@ -90,31 +64,30 @@ export default function CreateAssignmentForm({
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="personId"
disabled={!!personId}
{...register("personId")}
id="recipientId"
{...register("recipientId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.personId ? "border-error" : ""
errors.recipientId ? "border-error" : ""
}`}
>
<option value="">{formCopy.personPlaceholder}</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
<option value="">Select a recipient</option>
{recipients.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors.personId && (
<p className="text-error">{errors.personId.message}</p>
{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">
{formCopy.itemLabel}
Item
</label>
<select
id="itemId"
@@ -123,7 +96,7 @@ export default function CreateAssignmentForm({
errors.itemId ? "border-error" : ""
}`}
>
<option value="">{formCopy.itemPlaceholder}</option>
<option value="">Select an item</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -135,7 +108,7 @@ export default function CreateAssignmentForm({
{itemId && itemAssets.length !== 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
{formCopy.assetLabel}
Asset
</label>
<select
id="assetId"
@@ -147,7 +120,7 @@ export default function CreateAssignmentForm({
: ""
}`}
>
<option value="">{formCopy.assetPlaceholder}</option>
<option value="">Select an asset</option>
{itemId
? itemAssets.map((asset) => (
<option key={asset.id} value={asset.id}>
@@ -161,32 +134,34 @@ export default function CreateAssignmentForm({
)}
</div>
)}
{itemId && itemAssets.length === 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
{formCopy.quantityLabel}
</label>
<input
type="number"
id="quantity"
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
placeholder={formCopy.quantityPlaceholder}
{...register("quantity")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors.quantity && (
<p className="text-error">{errors.quantity.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
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (itemAssets.length > 0 && !assetId)}
>
{formCopy.createSubmit}
Create Assignment
</SubmitButton>
</form>
)
@@ -2,209 +2,51 @@
import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState, useTransition } from "react"
import { useTransition } from "react"
import { toast } from "sonner"
import { returnAssignment } from "@/actions/assignment.actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import type { Dictionary } from "@/i18n/dictionaries"
import type { ReturnAssignmentFormType } from "@/schemas/assignment.schema"
type PartialReturnCopy = Dictionary["inventory"]["assignments"]["partialReturn"]
const defaultPartialReturnCopy: PartialReturnCopy = {
title: "Devolver artículo",
quantity: "Cantidad",
quantityPlaceholder: "1",
notes: "Notas",
notesPlaceholder: "Notas opcionales",
submit: "Devolver",
cancel: "Cancelar",
maxQuantity: "Máximo: {max}",
errorConcurrent:
"La devolución fue modificada por otro usuario. Recarga e inténtalo de nuevo.",
errorGeneric: "Error al procesar la devolución",
}
import { returnAssignment } from "@/lib/actions/assignament.actions"
import { ReturnAssignmentFormType } from "@/lib/schemas/assignment.schemas"
export default function ReturnButton({
assignmentId,
ariaLabel,
assignmentLineId,
remainingQuantity,
copy = defaultPartialReturnCopy,
}: {
assignmentId: string
ariaLabel: string
assignmentLineId?: string
remainingQuantity?: number
copy?: PartialReturnCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [open, setOpen] = useState(false)
const [quantity, setQuantity] = useState(1)
const [notes, setNotes] = useState("")
const [errorKey, setErrorKey] = useState<
"errorConcurrent" | "errorGeneric" | null
>(null)
const isQuantityMode =
assignmentLineId !== undefined && remainingQuantity !== undefined
const isOverMax = isQuantityMode && quantity > remainingQuantity
const canSubmit = isQuantityMode
? quantity >= 1 && quantity <= remainingQuantity && !isPending
: !isPending
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuantity(1)
setNotes("")
setErrorKey(null)
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!canSubmit) return
setErrorKey(null)
const formData: ReturnAssignmentFormType = isQuantityMode
? {
id: assignmentId,
returns: [
{
assignmentLineId,
quantity,
notes: notes.trim() || undefined,
},
],
}
: { id: assignmentId }
const handleReturn = (formData: ReturnAssignmentFormType) => {
startTransition(async () => {
const response = await returnAssignment(formData)
if (response.success) {
setOpen(false)
setQuantity(1)
setNotes("")
toast.success(response.message)
router.refresh()
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
return
}
if (response.errors?.error?.includes("errorConcurrent")) {
setErrorKey("errorConcurrent")
if (response.success) {
toast.success(response.message)
router.refresh()
} else {
setErrorKey("errorGeneric")
toast.error(response.message ?? "Unknown error")
}
})
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
type="button"
className="btn btn-error"
size="icon"
variant="outline"
aria-label={ariaLabel}
disabled={isPending}
>
<ArrowLeft />
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription className="sr-only">
{copy.title}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{isQuantityMode && (
<>
<div className="grid gap-2">
<label
htmlFor={`quantity-${assignmentId}`}
className="text-sm font-medium"
>
{copy.quantity}
</label>
<Input
id={`quantity-${assignmentId}`}
type="number"
min={1}
max={remainingQuantity}
value={quantity}
onChange={(event) =>
setQuantity(Number(event.target.value))
}
placeholder={copy.quantityPlaceholder}
aria-invalid={isOverMax || undefined}
/>
{isOverMax && (
<p className="text-destructive text-sm">
{copy.maxQuantity.replace(
"{max}",
String(remainingQuantity),
)}
</p>
)}
</div>
<div className="grid gap-2">
<label
htmlFor={`notes-${assignmentId}`}
className="text-sm font-medium"
>
{copy.notes}
</label>
<textarea
id={`notes-${assignmentId}`}
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder={copy.notesPlaceholder}
className="min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30"
/>
</div>
</>
)}
{errorKey && (
<p className="text-destructive text-sm" role="alert">
{copy[errorKey]}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
{copy.cancel}
</Button>
<Button type="submit" disabled={!canSubmit}>
{copy.submit}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<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>
)
}
+3 -18
View File
@@ -1,30 +1,15 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { PersonService } from "@/services/person.service"
import { RecipientService } from "@/services/recipient.service"
import AssignmentForm from "../_components/new.assignment.form"
export default async function NewAssignmentPage() {
const people = await PersonService.findAll()
const recipients = await RecipientService.findAll()
const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAllAvailable()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<AssignmentForm
people={people}
items={items}
assets={assets}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
<AssignmentForm recipients={recipients} items={items} assets={assets} />
)
}
+15 -44
View File
@@ -4,7 +4,6 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import ReturnButton from "./_components/return.button"
@@ -16,46 +15,39 @@ export default async function AssignmentsPage(props: {
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: assignments, totalPages } =
await AssignmentService.findAllWithPersonPaginated({
await AssignmentService.findAllWithRecipientPaginated({
page: currentPage,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
title="Assignments"
link="/assignments/new"
searchable={true}
search={search}
data={assignments}
addLabel={copy.list.addLabel}
/>
{assignments.length === 0 && <div>{copy.list.empty}</div>}
{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">
{copy.list.columns.person}
Recipient
</th>
<th scope="col" className="p-4">
{copy.list.columns.item}
Item
</th>
<th scope="col" className="p-4">
{copy.list.columns.serialNumber}
Serial Number
</th>
<th scope="col" className="p-4">
{copy.list.columns.quantity}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
Actions
</th>
</tr>
</thead>
@@ -64,11 +56,11 @@ export default async function AssignmentsPage(props: {
<tr key={assignment.id} className="border-b">
<td className="p-4">
<Link
href={`/people/${assignment?.person?.id}`}
href={`/recipients/${assignment?.recipient?.id}`}
className="hover:underline"
>
{assignment?.person?.firstName}{" "}
{assignment?.person?.lastName}
{assignment?.recipient?.firstName}{" "}
{assignment?.recipient?.lastName}
</Link>
</td>
<td className="p-4">
@@ -80,19 +72,7 @@ export default async function AssignmentsPage(props: {
</Link>
</td>
<td className="p-4">
{assignment?.asset?.serialNumber ||
copy.fallback.missingValue}
</td>
<td className="p-4">
{assignment.status === "PARTIALLY_RETURNED" &&
assignment.remainingQuantity !== undefined
? `${copy.remaining.label}: ${copy.remaining.value
.replace(
"{remaining}",
String(assignment.remainingQuantity),
)
.replace("{total}", String(assignment.quantity))}`
: assignment?.quantity}
{assignment?.asset?.serialNumber || "N/A"}
</td>
<td className="p-4">
<div className="flex gap-2">
@@ -100,20 +80,11 @@ export default async function AssignmentsPage(props: {
href={`/assignments/${assignment.id}/edit`}
passHref
>
<Button
variant="outline"
aria-label={copy.list.actions.edit}
>
<Button variant="outline">
<Pencil />
</Button>
</Link>
<ReturnButton
assignmentId={assignment.id}
ariaLabel={copy.list.actions.return}
assignmentLineId={assignment.assignmentLineId}
remainingQuantity={assignment.remainingQuantity}
copy={copy.partialReturn}
/>
<ReturnButton assignmentId={assignment.id} />
</div>
</td>
</tr>
@@ -121,7 +92,7 @@ export default async function AssignmentsPage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<td colSpan={4} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
@@ -2,23 +2,19 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import type { ChangeEvent } from "react"
import { ChangeEvent } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { importItems } from "@/actions/import.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { type ImportFormType, importSchema } from "@/schemas/import.schema"
import type { CategorySummary } from "@/types"
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,
submitButtonCopy,
}: {
categories: CategorySummary[]
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
@@ -100,7 +96,6 @@ export default function ImportForm({
)}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!file}
+2 -7
View File
@@ -2,7 +2,6 @@ import { Download } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ENVIRONMENT } from "@/lib/constants"
import { CategoryService } from "@/services/category.service"
@@ -10,14 +9,13 @@ import ImportForm from "./_components/import.form"
export default async function ImportPage() {
const categories = await CategoryService.findAllWithItemsCount()
const { dictionary } = await getI18n()
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">
{(ENVIRONMENT === "development" || ENVIRONMENT === "demo") && (
{ENVIRONMENT === "demo" && (
<Link href="/sample_data.csv" download>
<Button variant="outline">
<Download />
@@ -32,10 +30,7 @@ export default async function ImportPage() {
</Button>
</Link>
</div>
<ImportForm
categories={categories}
submitButtonCopy={dictionary.common.submitButton}
/>
<ImportForm categories={categories} />
</div>
)
}
@@ -1,10 +1,9 @@
"use server"
import { getI18n } from "@/i18n/server"
import { AssetWithAssignment } from "@/lib/types"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { PersonService } from "@/services/person.service"
import type { AssetWithAssignment } from "@/types"
import { RecipientService } from "@/services/recipient.service"
import EditAssetForm from "../../_components/edit.asset.form"
@@ -15,28 +14,22 @@ export default async function EditAssetPage({
}) {
const { assetId } = await params
const items = await ItemService.findAll()
const people = await PersonService.findAll()
const recipients = await RecipientService.findAll()
const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
if (!asset) {
return <div>{copy.edit.notFound}</div>
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">{copy.edit.title}</h1>
<h1 className="text-2xl font-bold">Edit Asset</h1>
</div>
<EditAssetForm
items={items}
people={people}
recipients={recipients}
asset={asset as unknown as AssetWithAssignment}
formCopy={copy.form}
schemaCopy={copy.schema}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
@@ -1,150 +0,0 @@
"use server"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type {
AssetDetailCopy,
AssetStatusCopy,
} from "../_components/asset.copy"
function formatAssetStatus(
status: string,
statusCopy: AssetStatusCopy,
fallback: { unknownStatus: string },
) {
return status in statusCopy
? statusCopy[status as keyof AssetStatusCopy]
: fallback.unknownStatus
}
function formatDate(value: Date | null | undefined, missingValue: string) {
return value ? value.toISOString().slice(0, 10) : missingValue
}
function formatPrice(
value: { toString(): string } | null | undefined,
missingValue: string,
) {
return value ? value.toString() : missingValue
}
function formatPersonName(
person:
| {
firstName?: string | null
lastName?: string | null
}
| null
| undefined,
missingValue: string,
) {
if (!person) return missingValue
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ")
return fullName || missingValue
}
export default async function AssetDetailPage({
params,
}: {
params: Promise<{ assetId: string }>
}) {
const { assetId } = await params
const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets.detail as AssetDetailCopy
const statusCopy = dictionary.inventory.assets.status
const missingValue = copy.fallback?.missingValue ?? "N/A"
if (!asset) {
return <div>{copy.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<PageHeader title={copy.title} data={[asset]} />
<dl className="grid gap-4 rounded-lg border p-4 md:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.item}</dt>
<dd>{asset.item?.name ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.serialNumber}
</dt>
<dd>{asset.serialNumber}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.assetTag}
</dt>
<dd>{asset.assetTag ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.manufacturer}
</dt>
<dd>{asset.manufacturer ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.model}</dt>
<dd>{asset.model ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.purchaseDate}
</dt>
<dd>{formatDate(asset.purchaseDate, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.purchasePrice}
</dt>
<dd>{formatPrice(asset.purchasePrice, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.warrantyEndsAt}
</dt>
<dd>{formatDate(asset.warrantyEndsAt, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.deliveryNote}
</dt>
<dd>{asset.deliveryNote ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.notes}</dt>
<dd>{asset.notes ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.status}
</dt>
<dd>
{formatAssetStatus(asset.status, statusCopy, {
unknownStatus: missingValue,
})}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.person}
</dt>
<dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd>
</div>
</dl>
<div>
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button variant="outline">{copy.actions.edit}</Button>
</Link>
</div>
</div>
)
}
@@ -1,9 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { AssetSchemaCopy } from "@/schemas/asset.schema"
export type AssetListCopy = Dictionary["inventory"]["assets"]["list"]
export type AssetFormCopy = Dictionary["inventory"]["assets"]["form"]
export type AssetDetailCopy = Dictionary["inventory"]["assets"]["detail"]
export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"]
export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"]
export type { AssetSchemaCopy }
@@ -2,53 +2,35 @@
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 { updateAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { updateAssetAction } from "@/lib/actions/asset.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
UpdateAssetFormType,
updateAssetSchema,
} from "@/lib/schemas/asset.schemas"
import {
buildUpdateAssetSchema,
type UpdateAssetFormType,
} from "@/schemas/asset.schema"
import type {
AssetWithAssignment,
Item,
Person,
Recipient,
UpdateAssetStatus,
} from "@/types"
import type {
AssetFormCopy,
AssetSchemaCopy,
AssetStatusCopy,
} from "./asset.copy"
} from "@/lib/types"
interface EditAssetFormProps {
asset: AssetWithAssignment
items: Item[]
people: Person[]
formCopy: AssetFormCopy
schemaCopy: AssetSchemaCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
recipients: Recipient[]
}
export default function EditAssetForm({
asset,
items,
people,
formCopy,
schemaCopy,
statusCopy,
submitButtonCopy,
recipients,
}: EditAssetFormProps) {
const router = useRouter()
const schema = useMemo(() => buildUpdateAssetSchema(schemaCopy), [schemaCopy])
const {
register,
@@ -57,14 +39,14 @@ export default function EditAssetForm({
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssetFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(updateAssetSchema),
defaultValues: {
id: asset.id,
itemId: asset.itemId ?? undefined,
itemId: asset.itemId ?? "",
serialNumber: asset.serialNumber,
deliveryNote: asset.deliveryNote ?? undefined,
deliveryNote: asset.deliveryNote ?? "",
status: asset.status as UpdateAssetStatus,
personId: asset.assignment?.personId ?? undefined,
recipientId: asset.assignment?.recipientId ?? "",
},
shouldFocusError: true,
mode: "onSubmit",
@@ -89,7 +71,7 @@ export default function EditAssetForm({
}
if (response?.success) {
toast.success(response.message)
toast.success("Asset updated successfully")
router.push(`/inventory/assets`)
}
}
@@ -97,16 +79,15 @@ export default function EditAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
</label>
<select
id="itemId"
defaultValue={asset.itemId}
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.itemPlaceholder}</option>
<option value="">Select a item:</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -119,13 +100,12 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
{formCopy.serialNumberLabel}
Serial Number
</label>
<input
type="text"
id="serialNumber"
placeholder={formCopy.serialNumberPlaceholder}
defaultValue={asset.serialNumber}
placeholder="Serial number"
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -133,110 +113,14 @@ export default function EditAssetForm({
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
defaultValue={asset.assetTag ?? undefined}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
defaultValue={asset.manufacturer ?? undefined}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
defaultValue={asset.model ?? undefined}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
defaultValue={asset.purchaseDate?.toISOString().slice(0, 10)}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
defaultValue={asset.purchasePrice?.toString()}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
defaultValue={asset.warrantyEndsAt?.toISOString().slice(0, 10)}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
{formCopy.deliveryNoteLabel}
Delivery Note
</label>
<input
type="text"
id="deliveryNote"
placeholder={formCopy.deliveryNotePlaceholder}
defaultValue={asset.deliveryNote ?? undefined}
placeholder="Delivery note"
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -246,18 +130,17 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
{formCopy.statusLabel}
Status
</label>
<select
id="status"
defaultValue={asset.status}
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.statusPlaceholder}</option>
{UPDATE_ASSET_STATUSES.map((status) => (
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
<option key={status} value={status}>
{statusCopy[status]}
{status}
</option>
))}
</select>
@@ -267,33 +150,31 @@ export default function EditAssetForm({
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="personId"
defaultValue={asset.assignment?.personId ?? undefined}
{...register("personId")}
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.personPlaceholder}</option>
{people?.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors?.personId && (
<p className="text-error">{errors.personId.message}</p>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
)}
</div>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
Update Asset
</SubmitButton>
</form>
)
@@ -2,46 +2,25 @@
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 { createAssetAction } from "@/actions/asset.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { CREATE_ASSET_STATUSES } from "@/lib/constants"
import {
buildCreateAssetSchema,
type CreateAssetFormType,
} from "@/schemas/asset.schema"
import type { ItemWithoutStock, Person } from "@/types"
import type {
AssetFormCopy,
AssetSchemaCopy,
AssetStatusCopy,
} from "./asset.copy"
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[]
people: Person[]
formCopy: AssetFormCopy
schemaCopy: AssetSchemaCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
recipients: Recipient[]
}
export default function NewAssetForm({
items,
people,
formCopy,
schemaCopy,
statusCopy,
submitButtonCopy,
}: NewAssetFormProps) {
export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
const router = useRouter()
const schema = useMemo(() => buildCreateAssetSchema(schemaCopy), [schemaCopy])
const {
register,
@@ -50,7 +29,7 @@ export default function NewAssetForm({
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssetFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(createAssetSchema),
defaultValues: {
status: "AVAILABLE",
},
@@ -77,7 +56,7 @@ export default function NewAssetForm({
}
if (response?.success) {
toast.success(response.message)
toast.success("Asset created successfully")
router.push(`/inventory/assets`)
}
}
@@ -85,15 +64,15 @@ export default function NewAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
<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="">{formCopy.itemPlaceholder}</option>
<option value="">Select a item:</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -106,12 +85,12 @@ export default function NewAssetForm({
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
{formCopy.serialNumberLabel}
Serial Number
</label>
<input
type="text"
id="serialNumber"
placeholder={formCopy.serialNumberPlaceholder}
placeholder="Serial number"
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -119,103 +98,14 @@ export default function NewAssetForm({
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
{formCopy.deliveryNoteLabel}
Delivery Note
</label>
<input
type="text"
id="deliveryNote"
placeholder={formCopy.deliveryNotePlaceholder}
placeholder="Delivery note"
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -225,17 +115,17 @@ export default function NewAssetForm({
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
{formCopy.statusLabel}
Status
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.statusPlaceholder}</option>
{CREATE_ASSET_STATUSES.map((status) => (
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
<option key={status} value={status}>
{statusCopy[status]}
{status}
</option>
))}
</select>
@@ -245,32 +135,31 @@ export default function NewAssetForm({
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="personId"
{...register("personId")}
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.personPlaceholder}</option>
{people?.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors?.personId && (
<p className="text-error">{errors.personId.message}</p>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
)}
</div>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
Create Asset
</SubmitButton>
</form>
)
@@ -1,30 +1,20 @@
"use server"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service"
import { PersonService } from "@/services/person.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 people = await PersonService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
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">{copy.new.title}</h1>
<h1 className="text-2xl font-bold">New Asset</h1>
</div>
<NewAssetForm
items={items}
people={people}
formCopy={copy.form}
schemaCopy={copy.schema}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
<NewAssetForm items={items} recipients={recipients} />
</div>
)
}
+11 -78
View File
@@ -4,32 +4,8 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type {
AssetFallbackCopy,
AssetStatusCopy,
} from "./_components/asset.copy"
function formatAssetStatus(
status: string,
statusCopy: AssetStatusCopy,
fallbackCopy: AssetFallbackCopy,
) {
return status in statusCopy
? statusCopy[status as keyof AssetStatusCopy]
: fallbackCopy.unknownStatus
}
function formatDate(value: Date | null | undefined) {
return value ? value.toISOString().slice(0, 10) : "—"
}
function formatPrice(value: { toString(): string } | null | undefined) {
return value ? value.toString() : "—"
}
export default async function AssetsPage(props: {
searchParams?: Promise<{
page?: string
@@ -37,7 +13,7 @@ export default async function AssetsPage(props: {
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: assets, totalPages } =
await AssetService.findAllWithItemAndCategory({
@@ -45,23 +21,19 @@ export default async function AssetsPage(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
title="Assets"
link="/inventory/assets/new"
data={assets}
searchable={true}
search={search}
addLabel={copy.list.addLabel}
/>
{assets.length === 0 && currentPage === 1 && (
<div className="flex gap-4">
<div className="flex items-center justify-between gap-4">
{copy.list.empty}
No Assets found.
</div>
</div>
)}
@@ -71,37 +43,19 @@ export default async function AssetsPage(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
{copy.list.columns.item}
Item Name
</th>
<th scope="col" className="p-4">
{copy.list.columns.category}
Category
</th>
<th scope="col" className="p-4">
{copy.list.columns.serialNumber}
Serial Number
</th>
<th scope="col" className="p-4">
{copy.list.columns.assetTag}
Status
</th>
<th scope="col" className="p-4">
{copy.list.columns.manufacturer}
</th>
<th scope="col" className="p-4">
{copy.list.columns.model}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchaseDate}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchasePrice}
</th>
<th scope="col" className="p-4">
{copy.list.columns.warrantyEndsAt}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
Actions
</th>
</tr>
</thead>
@@ -111,31 +65,10 @@ export default async function AssetsPage(props: {
<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.assetTag ?? "—"}</td>
<td className="p-4">{asset.manufacturer ?? "—"}</td>
<td className="p-4">{asset.model ?? "—"}</td>
<td className="p-4">{formatDate(asset.purchaseDate)}</td>
<td className="p-4">{formatPrice(asset.purchasePrice)}</td>
<td className="p-4">{formatDate(asset.warrantyEndsAt)}</td>
<td className="p-4">
{formatAssetStatus(
asset.status,
copy.status,
copy.fallback,
)}
</td>
<td className="p-4">{asset.status}</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/assets/${asset.id}`} passHref>
<Button variant="outline" size="sm">
{copy.list.actions.view}
</Button>
</Link>
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
@@ -145,7 +78,7 @@ export default async function AssetsPage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={11} className="p-4 text-center text-sm">
<td colSpan={5} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
@@ -1,6 +1,5 @@
import { notFound } from "next/navigation"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import EditCategoryForm from "../../_components/edit.category.form"
@@ -12,8 +11,6 @@ export default async function EditCategoryPage({
}) {
const { categoryId } = await params
const category = await CategoryService.findById(categoryId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
if (!category) {
notFound()
@@ -22,14 +19,9 @@ export default async function EditCategoryPage({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
<h1 className="text-2xl font-bold">Edit Category</h1>
</div>
<EditCategoryForm
category={category}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
<EditCategoryForm category={category} />
</div>
)
}
@@ -1,6 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { CategorySchemaCopy } from "@/schemas/category.schema"
export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"]
export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"]
export type { CategorySchemaCopy }
@@ -4,17 +4,14 @@ import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { deleteCategoryAction } from "@/actions/category.actions"
import { Button } from "@/components/ui/button"
import type { CategoryDeleteCopy } from "./category.copy"
import { Button } from "@/components/ui/button"
import { deleteCategoryAction } from "@/lib/actions/category.actions"
export default function DeleteCategoryButton({
categoryId,
copy,
}: {
categoryId: string
copy: CategoryDeleteCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@@ -32,7 +29,7 @@ export default function DeleteCategoryButton({
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? copy.unknownError)
toast.error(response.message ?? "Unknown error")
}
})
}
@@ -46,7 +43,6 @@ export default function DeleteCategoryButton({
size="icon"
variant="outline"
disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
>
<Trash />
</Button>
@@ -2,37 +2,23 @@
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 { updateCategoryAction } from "@/actions/category.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateCategoryAction } from "@/lib/actions/category.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUpdateCategorySchema,
type UpdateCategoryFormType,
} from "@/schemas/category.schema"
import type { CategorySummary } from "@/types"
import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
UpdateCategoryFormType,
updateCategorySchema,
} from "@/lib/schemas/category.schemas"
import { CategorySummary } from "@/lib/types"
export default function EditCategoryForm({
category,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
category: CategorySummary
formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(
() => buildUpdateCategorySchema(schemaCopy),
[schemaCopy],
)
const {
register,
@@ -40,7 +26,7 @@ export default function EditCategoryForm({
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateCategoryFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(updateCategorySchema),
defaultValues: {
id: category.id,
name: category.name,
@@ -74,12 +60,12 @@ export default function EditCategoryForm({
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg">
{formCopy.nameLabel}
Name
</label>
<input
type="text"
id="name"
placeholder={formCopy.namePlaceholder}
placeholder="Category name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
@@ -88,11 +74,10 @@ export default function EditCategoryForm({
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
Update Category
</SubmitButton>
</form>
)
@@ -2,34 +2,18 @@
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 { createCategoryAction } from "@/actions/category.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildCreateCategorySchema,
type CreateCategoryFormType,
} from "@/schemas/category.schema"
import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function NewCategoryForm({
formCopy,
schemaCopy,
submitButtonCopy,
}: {
formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
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 schema = useMemo(
() => buildCreateCategorySchema(schemaCopy),
[schemaCopy],
)
const {
register,
@@ -37,7 +21,7 @@ export default function NewCategoryForm({
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateCategoryFormType>({
resolver: zodResolver(schema),
resolver: zodResolver(createCategorySchema),
})
const onSubmit = async (formData: CreateCategoryFormType) => {
@@ -66,12 +50,12 @@ export default function NewCategoryForm({
<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">
{formCopy.nameLabel}
Name
</label>
<input
type="text"
id="name"
placeholder={formCopy.namePlaceholder}
placeholder="Category name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
@@ -80,11 +64,10 @@ export default function NewCategoryForm({
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
Create Category
</SubmitButton>
</form>
)
@@ -1,21 +1,12 @@
import { getI18n } from "@/i18n/server"
import NewCategoryForm from "../_components/new.category.form"
export default async function NewCategoryPage() {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
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">{copy.new.title}</h1>
<h1 className="text-2xl font-bold">New Category</h1>
</div>
<NewCategoryForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
<NewCategoryForm />
</div>
)
}
@@ -4,7 +4,6 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import DeleteCategoryButton from "./_components/delete.category.button"
@@ -16,7 +15,7 @@ export default async function Items(props: {
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: categories, totalPages } =
await CategoryService.findAllWithItemsCountPaginated({
@@ -24,23 +23,18 @@ export default async function Items(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
addLabel={copy.list.addLabel}
title="Categories"
link="/inventory/categories/new"
data={categories}
searchable={true}
search={search}
/>
{categories.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
{copy.list.empty}
No Categories found.
</div>
</div>
)}
@@ -50,13 +44,13 @@ export default async function Items(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
{copy.list.columns.name}
Name
</th>
<th scope="col" className="p-4">
{copy.list.columns.items}
Items
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
Actions
</th>
</tr>
</thead>
@@ -74,16 +68,12 @@ export default async function Items(props: {
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
{category._count.items === 0 && (
<DeleteCategoryButton
categoryId={category.id}
copy={copy.delete}
/>
<DeleteCategoryButton categoryId={category.id} />
)}
</td>
</tr>
@@ -1,4 +1,3 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service"
@@ -12,30 +11,22 @@ export default async function AddItem({
const { itemId } = await params
const categories = await CategoryService.findAll()
const item = await ItemService.findByIdWithAssetCount(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
if (!item) {
return <div>{copy.edit.notFound}</div>
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>{copy.edit.hasAssetsWarning}</p>
<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">{copy.edit.title}</h1>
<h1 className="text-2xl font-bold">Edit Item</h1>
</div>
<UpdateItemForm
categories={categories}
item={item}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
<UpdateItemForm categories={categories} item={item} />
</div>
)
}
@@ -1,6 +1,4 @@
import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
@@ -14,12 +12,9 @@ export default async function ItemPage({
const item = await ItemService.findByIdWithCategory(itemId)
const assets = await AssetService.findByItemId(itemId)
const movements = await MovementService.findAllByItemId(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items.detail
const movementCopy = dictionary.inventory.movements
if (!item) {
return <div>{copy.notFound}</div>
return <div>Item not found</div>
}
return (
@@ -31,11 +26,11 @@ export default async function ItemPage({
<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">{copy.labels.category}</span>
<span className="text-gray-600">Category</span>
<span>{item.category.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{copy.labels.stock}</span>
<span className="text-gray-600">Stock</span>
<span>{item.stock}</span>
</div>
</div>
@@ -79,7 +74,7 @@ export default async function ItemPage({
{movements?.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{movementCopy.snippet.title}</CardTitle>
<CardTitle>Movements</CardTitle>
</CardHeader>
<CardContent>
{movements.map((movement) => (
@@ -88,21 +83,11 @@ export default async function ItemPage({
className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
>
<div className="flex justify-between">
<span className="text-gray-600">
{movementCopy.snippet.labels.type}
</span>
<span>
{formatMovementType(
movement.type,
movementCopy.types,
movementCopy.fallback,
)}
</span>
<span className="text-gray-600">Type</span>
<span>{movement.type}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
{movementCopy.snippet.labels.quantity}
</span>
<span className="text-gray-600">Quantity</span>
<span>{movement.quantity}</span>
</div>
</div>
@@ -4,18 +4,11 @@ import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { deleteItemAction } from "@/actions/item.actions"
import { Button } from "@/components/ui/button"
import { deleteItemAction } from "@/lib/actions/item.actions"
import type { ItemDeleteCopy } from "./item.copy"
export default function DeleteItemButton({
itemId,
copy,
}: {
itemId: string
copy: ItemDeleteCopy
}) {
export default function DeleteItemButton({ itemId }: { itemId: string }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@@ -32,7 +25,7 @@ export default function DeleteItemButton({
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? copy.unknownError)
toast.error(response.message ?? "Unknown error")
}
})
}
@@ -46,7 +39,6 @@ export default function DeleteItemButton({
size="icon"
variant="outline"
disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
>
<Trash />
</Button>
@@ -1,8 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { ItemSchemaCopy } from "@/schemas/item.schema"
export type ItemListCopy = Dictionary["inventory"]["items"]["list"]
export type ItemDetailCopy = Dictionary["inventory"]["items"]["detail"]
export type ItemFormCopy = Dictionary["inventory"]["items"]["form"]
export type ItemDeleteCopy = Dictionary["inventory"]["items"]["delete"]
export type { ItemSchemaCopy }
@@ -1,51 +1,37 @@
"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 { createItemAction } from "@/actions/item.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildCreateItemResolver,
buildCreateItemSchema,
type CreateItemData,
type CreateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary } from "@/types"
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
import StockPolicyFields from "./stock-policy-fields"
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,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
categories: CategorySummary[]
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType, unknown, CreateItemData>({
resolver: buildCreateItemResolver(schema),
} = useForm<CreateItemFormType>({
resolver: zodResolver(createItemSchema),
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: CreateItemData) => {
const onSubmit = async (formData: CreateItemFormType) => {
const response = await createItemAction(formData)
if (response?.errors) {
@@ -63,7 +49,7 @@ export default function NewItemForm({
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items")
router.push("/inventory/items ")
}
}
@@ -71,29 +57,27 @@ export default function NewItemForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name" className="mb-2 block text-lg">
{formCopy.nameLabel}
Name
</label>
<input
type="text"
id="name"
placeholder={formCopy.namePlaceholder}
placeholder="Item name"
{...register("name")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
{errors?.name && <p className="text-error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
{formCopy.categoryLabel}
Category
</label>
<select
id="categoryId"
{...register("categoryId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.categoryPlaceholder}</option>
<option value="">Select a category</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -101,18 +85,18 @@ export default function NewItemForm({
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message as string}</p>
<p className="text-error">{errors.categoryId.message}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
{formCopy.stockLabel}
Stock
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder={formCopy.stockPlaceholder}
placeholder="0"
min="0"
{...register("stock")}
className="w-full rounded-lg border px-4 py-2"
@@ -129,28 +113,13 @@ export default function NewItemForm({
}
}}
/>
{errors?.stock && (
<p className="text-error">{errors.stock.message as string}</p>
)}
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
</div>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
Create Item
</SubmitButton>
</form>
)
@@ -1,91 +0,0 @@
"use client"
import type { FieldErrors, Path, UseFormRegister } from "react-hook-form"
type StockPolicyFieldsCopy = {
title: string
description: string
minStockLabel: string
minStockPlaceholder: string
targetStockLabel: string
targetStockPlaceholder: string
}
type StockPolicyFieldValues = {
minStock?: number | string | null
targetStock?: number | string | null
}
type StockPolicyFieldsProps<TFieldValues extends StockPolicyFieldValues> = {
copy: StockPolicyFieldsCopy
register: UseFormRegister<TFieldValues>
errors?: FieldErrors<TFieldValues>
}
function StockPolicyNumericInput<TFieldValues extends StockPolicyFieldValues>({
id,
label,
placeholder,
error,
register,
}: {
id: "minStock" | "targetStock"
label: string
placeholder: string
error?: string
register: UseFormRegister<TFieldValues>
}) {
return (
<div>
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
id={id}
type="number"
min="0"
step="1"
placeholder={placeholder}
{...register(id as Path<TFieldValues>)}
className="w-full rounded-lg border px-4 py-2"
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
export default function StockPolicyFields<
TFieldValues extends StockPolicyFieldValues,
>({ copy, register, errors }: StockPolicyFieldsProps<TFieldValues>) {
const minStockMessage = errors?.minStock?.message
const targetStockMessage = errors?.targetStock?.message
const minStockError =
typeof minStockMessage === "string" ? minStockMessage : undefined
const targetStockError =
typeof targetStockMessage === "string" ? targetStockMessage : undefined
return (
<section className="rounded-lg border p-4">
<div className="mb-4 space-y-1">
<h2 className="text-lg font-medium">{copy.title}</h2>
<p className="text-muted-foreground text-sm">{copy.description}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StockPolicyNumericInput<TFieldValues>
id="minStock"
label={copy.minStockLabel}
placeholder={copy.minStockPlaceholder}
error={minStockError}
register={register}
/>
<StockPolicyNumericInput<TFieldValues>
id="targetStock"
label={copy.targetStockLabel}
placeholder={copy.targetStockPlaceholder}
error={targetStockError}
register={register}
/>
</div>
</section>
)
}
@@ -1,40 +1,26 @@
"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 { updateItemAction } from "@/actions/item.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUpdateItemResolver,
buildUpdateItemSchema,
type UpdateItemData,
type UpdateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary, ItemWithAssetCount } from "@/types"
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
import StockPolicyFields from "./stock-policy-fields"
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,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
categories: CategorySummary[]
item: ItemWithAssetCount
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy])
const isDisabled = !!item?._count.assets && item?._count.assets > 0
@@ -43,21 +29,19 @@ export default function UpdateItemForm({
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType, unknown, UpdateItemData>({
resolver: buildUpdateItemResolver(schema),
} = useForm<UpdateItemFormType>({
resolver: zodResolver(updateItemSchema),
defaultValues: {
id: item?.id,
name: item?.name,
categoryId: item?.category.id,
stock: item?.stock,
minStock: item?.minStock ?? undefined,
targetStock: item?.targetStock ?? undefined,
},
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: UpdateItemData) => {
const onSubmit = async (formData: UpdateItemFormType) => {
const response = await updateItemAction(formData)
if (response?.errors) {
@@ -75,7 +59,7 @@ export default function UpdateItemForm({
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items")
router.push("/inventory/items ")
}
}
@@ -84,22 +68,20 @@ export default function UpdateItemForm({
{item?.id && <input type="hidden" name="id" value={item.id} />}
<div>
<label htmlFor="name" className="mb-2 block text-lg">
{formCopy.nameLabel}
Name
</label>
<input
type="text"
id="name"
placeholder={formCopy.namePlaceholder}
placeholder="Item name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
{errors?.name && <p className="text-error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
{formCopy.categoryLabel}
Category
</label>
<select
id="categoryId"
@@ -107,7 +89,7 @@ export default function UpdateItemForm({
{...register("categoryId")}
className={`w-full rounded-lg border px-4 py-2`}
>
<option value="">{formCopy.categoryPlaceholder}</option>
<option value="">Select a category</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -115,18 +97,18 @@ export default function UpdateItemForm({
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message as string}</p>
<p className="text-error">{errors.categoryId.message}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
{formCopy.stockLabel}
Stock
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder={formCopy.stockPlaceholder}
placeholder="0"
min={item.stock}
disabled={isDisabled}
{...register("stock")}
@@ -146,28 +128,13 @@ export default function UpdateItemForm({
}
}}
/>
{errors?.stock && (
<p className="text-error">{errors.stock.message as string}</p>
)}
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
</div>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
Update Item
</SubmitButton>
</form>
)
@@ -1,24 +1,16 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import NewItemForm from "../_components/new.item.form"
export default async function NewItemPage() {
const categories = await CategoryService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
<h1 className="text-2xl font-bold">New Item</h1>
</div>
<NewItemForm
categories={categories}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
<NewItemForm categories={categories} />
</div>
)
}
+12 -44
View File
@@ -4,24 +4,10 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service"
import DeleteItemButton from "./_components/delete.item.button"
function formatStockPolicy(
item: { minStock: number | null; targetStock: number | null },
copy: { configured: string; none: string },
) {
if (item.minStock === null || item.targetStock === null) {
return copy.none
}
return copy.configured
.replace("{min}", String(item.minStock))
.replace("{target}", String(item.targetStock))
}
export default async function ItemsPage(props: {
searchParams?: Promise<{
page?: string
@@ -29,30 +15,26 @@ export default async function ItemsPage(props: {
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: items, totalPages } = await ItemService.findAllWithAssetCount({
page: currentPage,
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
title="Items"
link="/inventory/items/new"
addLabel={copy.list.addLabel}
data={items}
searchable={true}
search={search}
/>
{items.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
{copy.list.empty}
No items found.
</div>
</div>
)}
@@ -62,22 +44,19 @@ export default async function ItemsPage(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
{copy.list.columns.name}
Name
</th>
<th scope="col" className="p-4">
{copy.list.columns.category}
Category
</th>
<th scope="col" className="p-4">
{copy.list.columns.assets}
Assets
</th>
<th scope="col" className="p-4">
{copy.list.columns.stock}
Stock
</th>
<th scope="col" className="p-4">
{copy.list.columns.stockPolicy}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
Actions
</th>
</tr>
</thead>
@@ -88,30 +67,19 @@ export default async function ItemsPage(props: {
<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="p-4">
{formatStockPolicy(item, copy.list.stockPolicy)}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/items/${item.id}`} passHref>
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.view}
>
<Button variant="outline" size="icon">
<Eye />
</Button>
</Link>
<Link href={`/inventory/items/${item.id}/edit`} passHref>
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
{item._count.assets === 0 && item.stock === 0 && (
<DeleteItemButton itemId={item.id} copy={copy.delete} />
<DeleteItemButton itemId={item.id} />
)}
</td>
</tr>
@@ -119,7 +87,7 @@ export default async function ItemsPage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<td colSpan={5} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
+1 -4
View File
@@ -3,18 +3,15 @@ import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { getI18n } from "@/i18n/server"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
const { dictionary } = await getI18n()
return (
<SidebarProvider>
<AppSidebar copy={dictionary.layout.sidebar} />
<AppSidebar />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
@@ -1,15 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type MovementTypeCopy = Dictionary["inventory"]["movements"]["types"]
export type MovementFallbackCopy =
Dictionary["inventory"]["movements"]["fallback"]
export function formatMovementType(
type: string,
typeCopy: MovementTypeCopy,
fallbackCopy: MovementFallbackCopy,
) {
return type in typeCopy
? typeCopy[type as keyof MovementTypeCopy]
: fallbackCopy.unknownType
}
+14 -29
View File
@@ -1,77 +1,62 @@
import PaginationButtons from "@/components/common/pagination"
import { getI18n } from "@/i18n/server"
import { formatDate } from "@/lib/utils"
import { MovementService } from "@/services/movement.service"
import { formatMovementType } from "./movement.copy"
export default async function MovementsPage(props: {
searchParams?: Promise<{
page?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const { data: movements, totalPages } = await MovementService.findAll({
page: currentPage,
pageSize: 12,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.movements
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.list.title}</h1>
<h1 className="text-2xl font-bold">Movements</h1>
</div>
{movements.length === 0 && <div>{copy.list.empty}</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">
{copy.list.columns.type}
Type
</th>
<th scope="col" className="p-4">
{copy.list.columns.item}
Item
</th>
<th scope="col" className="p-4">
{copy.list.columns.serialNumber}
Serial Number
</th>
<th scope="col" className="p-4">
{copy.list.columns.quantity}
Quantity
</th>
<th scope="col" className="p-4">
{copy.list.columns.person}
Recipient
</th>
<th scope="col" className="p-4">
{copy.list.columns.date}
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">
{formatMovementType(
movement.type,
copy.types,
copy.fallback,
)}
</td>
<td className="p-4">
{movement?.item?.name || copy.fallback.missingValue}
</td>
<td className="p-4">
{movement?.asset?.serialNumber ||
copy.fallback.missingValue}
{movement?.asset?.serialNumber || "-"}
</td>
<td className="p-4">{movement.quantity}</td>
<td className="p-4">
{movement?.person
? `${movement.person.firstName} ${movement.person.lastName}`
: copy.fallback.missingValue}
{movement?.recipient?.firstName || "-"}{" "}
{movement?.recipient?.lastName || "-"}
</td>
<td className="p-4">{formatDate(movement.createdAt)}</td>
</tr>
@@ -1,38 +0,0 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import EditPersonForm from "../../_components/people/edit.person.form"
export default async function PersonEditPage({
params,
}: {
params: Promise<{ personId: string }>
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const teams = await listTeamsUseCase()
if (!person) {
return <div>{personCopy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{personCopy.edit.title}</h1>
</div>
<EditPersonForm
person={person}
formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
}
@@ -1,107 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "../_components/people/user.copy"
export default async function PersonInfoPage({
params,
}: {
params: Promise<{ personId: string }>
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const assignmentCopy = dictionary.inventory.assignments
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const assignments = await AssignmentService.findAllByPerson(personId)
if (!person) {
return <div>{copy.detail.notFound}</div>
}
return (
<div className="grid gap-6">
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{`${person.firstName} ${person.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">{copy.detail.labels.email}</span>
<span>{person.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{copy.detail.labels.phone}</span>
<span>{person.phone}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{copy.detail.labels.team}</span>
<span>{person.team?.name ?? copy.fallback.noTeam}</span>
</div>
{person.user ? (
<>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.role}
</span>
<span>
{formatUserRole(
person.user.role,
userCopy.roles as UserRoleCopy,
userCopy.fallback as UserFallbackCopy,
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.status}
</span>
<span>
{person.user.status === UserStatus.ACTIVE
? userCopy.status.active
: userCopy.status.inactive}
</span>
</div>
</>
) : (
<div className="col-span-2 flex justify-between">
<span className="text-gray-600">{copy.detail.labels.role}</span>
<span>{copy.detail.labels.noUser}</span>
</div>
)}
</div>
</CardContent>
</Card>
{assignments.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{assignmentCopy.list.title}</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>
)
}
@@ -1,265 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updatePersonUserAction } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUnifiedUpdateSchema,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import type { PersonWithUser } from "@/services/person.service"
import type { TeamSummary } from "@/types"
import {
formatUserRole,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
export default function EditPersonForm({
person,
formCopy,
schemaCopy,
roleLabels,
submitButtonCopy,
teams,
}: {
person: PersonWithUser
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
() => buildUnifiedUpdateSchema(schemaCopy),
[schemaCopy],
)
const hasUser = Boolean(person.userId && person.user)
const user = person.user
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UnifiedUpdateFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: person.teamId ?? null,
email: person.email ?? "",
phone: person.phone ?? "",
...(hasUser && user
? { role: user.role, isActive: user.status === "ACTIVE" }
: {}),
},
})
const onSubmit = async (formData: UnifiedUpdateFormType) => {
const response = await updatePersonUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UnifiedUpdateFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/people")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<TextInput
error={errors.firstName?.message}
id="firstName"
label={formCopy.firstNameLabel}
placeholder={formCopy.firstNamePlaceholder}
register={register("firstName")}
/>
<TextInput
error={errors.lastName?.message}
id="lastName"
label={formCopy.lastNameLabel}
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
register={register("teamId")}
teams={teams}
/>
<TextInput
error={errors.email?.message}
id="email"
label={formCopy.emailLabel}
placeholder={formCopy.emailPlaceholder}
register={register("email")}
type="email"
/>
<TextInput
error={errors.phone?.message}
id="phone"
label={formCopy.phoneLabel}
placeholder={formCopy.phonePlaceholder}
register={register("phone")}
/>
{hasUser && (
<section
className="flex flex-col gap-4 border-t pt-4"
aria-labelledby="user-account-heading"
>
<h2 id="user-account-heading" className="text-xl font-semibold">
{formCopy.userAccountHeading}
</h2>
<RoleSelect
register={register("role")}
roleLabel={formCopy.roleLabel}
roleLabels={roleLabels}
/>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("isActive")} />
{formCopy.activeLabel}
</label>
<TextInput
error={errors.password?.message}
id="password"
label={formCopy.newPasswordLabel}
placeholder={formCopy.newPasswordPlaceholder}
register={register("password")}
type="password"
/>
</section>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
}
function TextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({
register,
roleLabel,
roleLabels,
}: {
register: UseFormRegisterReturn
roleLabel: string
roleLabels: UserRoleCopy
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
{roleLabel}
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">{roleLabels.ADMIN}</option>
<option value="MANAGER">{roleLabels.MANAGER}</option>
<option value="STAFF">{roleLabels.STAFF}</option>
<option value="VIEWER">{roleLabels.VIEWER}</option>
</select>
</div>
)
}
function TeamSelect({
error,
formCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
{error && <p className="text-error">{error}</p>}
</div>
)
}
// Re-export for tests that need to verify the data shape passed to this form.
export { formatUserRole }
@@ -1,243 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createPersonUserAction } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUnifiedCreateSchema,
type UnifiedCreateFormType,
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
import type { TeamSummary } from "@/types"
import type { UserFormCopy, UserRoleCopy } from "./user.copy"
export default function NewUserForm({
formCopy,
schemaCopy,
roleLabels,
submitButtonCopy,
teams,
}: {
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
() => buildUnifiedCreateSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
watch,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UnifiedCreateFormType>({
resolver: zodResolver(schema),
defaultValues: {
role: "NO_USER",
isActive: true,
},
})
const selectedRole = watch("role")
const showPassword = selectedRole !== "NO_USER"
const onSubmit = async (formData: UnifiedCreateFormType) => {
const response = await createPersonUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UnifiedCreateFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/people")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<UserTextInput
error={errors.firstName?.message}
id="firstName"
label={formCopy.firstNameLabel}
placeholder={formCopy.firstNamePlaceholder}
register={register("firstName")}
/>
<UserTextInput
error={errors.lastName?.message}
id="lastName"
label={formCopy.lastNameLabel}
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label={formCopy.emailLabel}
placeholder={formCopy.emailPlaceholder}
register={register("email")}
type="email"
/>
<UserTextInput
error={errors.phone?.message}
id="phone"
label={formCopy.phoneLabel}
placeholder={formCopy.phonePlaceholder}
register={register("phone")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
register={register("teamId")}
teams={teams}
/>
<RoleSelect
register={register("role")}
roleLabel={formCopy.roleLabel}
roleLabels={roleLabels}
/>
{showPassword && (
<UserTextInput
error={errors.password?.message}
id="password"
label={formCopy.passwordLabel}
placeholder={formCopy.passwordPlaceholder}
register={register("password")}
type="password"
/>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
</SubmitButton>
</div>
</div>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({
register,
roleLabel,
roleLabels,
}: {
register: UseFormRegisterReturn
roleLabel: string
roleLabels: UserRoleCopy
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
{roleLabel}
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">{roleLabels.ADMIN}</option>
<option value="MANAGER">{roleLabels.MANAGER}</option>
<option value="STAFF">{roleLabels.STAFF}</option>
<option value="VIEWER">{roleLabels.VIEWER}</option>
<option value="NO_USER">{roleLabels.NO_USER}</option>
</select>
</div>
)
}
function TeamSelect({
error,
formCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
{error && <p className="text-error">{error}</p>}
</div>
)
}
@@ -1,153 +0,0 @@
import { Eye, Pencil, UserPlus } 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 { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "./user.copy"
export default async function PersonPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: people, totalPages } = await PersonService.findAllPaginated({
page: currentPage,
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const personFallbackCopy = copy.fallback
return (
<div className="flex flex-col gap-4 mt-4">
<PageHeader
title={copy.list.title}
link="/people/new"
addLabel={copy.list.addLabel}
data={people}
searchable={true}
search={search}
/>
{people.length === 0 && <div>{copy.list.empty}</div>}
{people.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">
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
{copy.list.columns.email}
</th>
<th scope="col" className="p-4">
{copy.list.columns.phone}
</th>
<th scope="col" className="p-4">
{copy.list.columns.team}
</th>
<th scope="col" className="p-4">
{copy.list.columns.role}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th>
</tr>
</thead>
<tbody>
{people.map((person) => (
<tr key={person.id} className="border-b">
<td className="p-4">
{`${person.firstName} ${person.lastName}`}
</td>
<td className="p-4">{person.email}</td>
<td className="p-4">{person.phone}</td>
<td className="p-4">
{person.team?.name ?? personFallbackCopy.noTeam}
</td>
<td className="p-4">
{person.user
? formatUserRole(
person.user.role,
userRoleLabels,
userFallbackCopy,
)
: "—"}
</td>
<td className="p-4">
{person.user
? person.user.status === UserStatus.ACTIVE
? userStatusCopy.active
: userStatusCopy.inactive
: "—"}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/people/${person.id}`} passHref>
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.view}
>
<Eye />
</Button>
</Link>
<Link href={`/people/${person.id}/edit`} passHref>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
<Link
href={`/assignments/new?personId=${person.id}`}
passHref
>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<UserPlus />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={7} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -1,6 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
@@ -1,19 +0,0 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type UserFormCopy = Dictionary["admin"]["users"]["form"]
export type UserRoleCopy = Dictionary["admin"]["users"]["roles"]
export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
export type UserResetPasswordCopy =
Dictionary["admin"]["users"]["resetPassword"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatUserRole(
role: string,
roleCopy: UserRoleCopy,
fallbackCopy: UserFallbackCopy,
): string {
return role in roleCopy
? roleCopy[role as keyof UserRoleCopy]
: fallbackCopy.unknownRole
}
@@ -1,29 +0,0 @@
import PageHeader from "@/components/common/pageheader"
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import TeamListTable from "./team.list.table"
export default async function TeamsPage() {
const teams = await listTeamsUseCase()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4 mt-4">
<PageHeader
title={copy.list.title}
link="/people/team/new"
addLabel={copy.list.addLabel}
data={teams}
/>
<TeamListTable
teams={teams}
formCopy={copy.form}
schemaCopy={copy.schema}
listCopy={copy.list}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -1,94 +0,0 @@
"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 { createTeamAction } from "@/actions/team.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildCreateTeamSchema,
type CreateTeamFormType,
} from "@/schemas/team.schema"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
export default function TeamCreateForm({
formCopy,
schemaCopy,
submitButtonCopy,
}: {
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildCreateTeamSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateTeamFormType>({
resolver: zodResolver(schema),
})
const onSubmit = async (formData: CreateTeamFormType) => {
const response = await createTeamAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateTeamFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
reset()
router.refresh()
}
}
return (
<form
className="flex flex-col gap-4 rounded-lg border p-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-2">
<label htmlFor="team-name" className="mb-2 block text-lg">
{formCopy.nameLabel}
</label>
<input
type="text"
id="team-name"
placeholder={formCopy.namePlaceholder}
{...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
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
</SubmitButton>
</form>
)
}
@@ -1,141 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Pencil } from "lucide-react"
import { useRouter } from "next/navigation"
import { useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateTeamAction } from "@/actions/team.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildUpdateTeamSchema,
type UpdateTeamFormType,
} from "@/schemas/team.schema"
import type { TeamSummary } from "@/types"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
export default function TeamEditForm({
team,
formCopy,
schemaCopy,
listCopy,
submitButtonCopy,
}: {
team: TeamSummary
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
listCopy: TeamListCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
const schema = useMemo(() => buildUpdateTeamSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateTeamFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: team.id,
name: team.name,
},
})
const onSubmit = async (formData: UpdateTeamFormType) => {
const response = await updateTeamAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof UpdateTeamFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
setOpen(false)
router.refresh()
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={listCopy.actions.edit}
>
<Pencil />
</Button>
</DialogTrigger>
<DialogContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>{formCopy.updateSubmit}</DialogTitle>
<DialogDescription>{team.name}</DialogDescription>
</DialogHeader>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label
htmlFor={`team-name-${team.id}`}
className="mb-2 block text-lg"
>
{formCopy.nameLabel}
</label>
<input
type="text"
id={`team-name-${team.id}`}
placeholder={formCopy.namePlaceholder}
{...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>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{formCopy.cancel}
</Button>
</DialogClose>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
</SubmitButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,113 +0,0 @@
"use client"
import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { deleteTeamAction } from "@/actions/team.actions"
import { Button } from "@/components/ui/button"
import type { Dictionary } from "@/i18n/dictionaries"
import type { TeamSummary } from "@/types"
import TeamEditForm from "./team.edit.form"
type TeamFormCopy = Dictionary["inventory"]["teams"]["form"]
type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"]
type TeamListCopy = Dictionary["inventory"]["teams"]["list"]
type SubmitButtonCopy = Dictionary["common"]["submitButton"]
function DeleteTeamButton({
team,
copy,
}: {
team: TeamSummary
copy: TeamListCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const handleDelete = (formData: FormData) => {
startTransition(async () => {
const response = await deleteTeamAction(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 ?? copy.actions.delete)
}
})
}
return (
<form action={handleDelete}>
<input type="hidden" name="id" value={team.id} />
<Button
type="submit"
variant="outline"
size="icon"
disabled={isPending}
aria-label={copy.actions.delete}
>
<Trash />
</Button>
</form>
)
}
export default function TeamListTable({
teams,
formCopy,
schemaCopy,
listCopy,
submitButtonCopy,
}: {
teams: TeamSummary[]
formCopy: TeamFormCopy
schemaCopy: TeamSchemaCopy
listCopy: TeamListCopy
submitButtonCopy: SubmitButtonCopy
}) {
if (teams.length === 0) {
return <div>{listCopy.empty}</div>
}
return (
<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">
{listCopy.columns.name}
</th>
<th scope="col" className="p-4">
{listCopy.columns.actions}
</th>
</tr>
</thead>
<tbody>
{teams.map((team) => (
<tr key={team.id} className="border-b">
<td className="p-4">{team.name}</td>
<td className="flex items-center gap-2 p-4">
<TeamEditForm
team={team}
formCopy={formCopy}
schemaCopy={schemaCopy}
listCopy={listCopy}
submitButtonCopy={submitButtonCopy}
/>
<DeleteTeamButton team={team} copy={listCopy} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
-13
View File
@@ -1,13 +0,0 @@
import type { ReactNode } from "react"
import { requireRole } from "@/services/auth.service"
export default async function PeopleLayout({
children,
}: {
children: ReactNode
}) {
await requireRole("ADMIN")
return children
}
-25
View File
@@ -1,25 +0,0 @@
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import NewPersonForm from "../_components/people/new.person.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const teams = await listTeamsUseCase()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewPersonForm
formCopy={copy.form}
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
roleLabels={copy.roles}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
}
-33
View File
@@ -1,33 +0,0 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getI18n } from "@/i18n/server"
import PersonPage from "./_components/people/page"
import TeamsPage from "./_components/team/page"
export default async function PeoplePage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const teamCopy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4">
<Tabs defaultValue="people">
<TabsList variant="line">
<TabsTrigger value="people">{copy.list.title}</TabsTrigger>
<TabsTrigger value="teams">{teamCopy.list.title}</TabsTrigger>
</TabsList>
<TabsContent value="people">
<PersonPage searchParams={props.searchParams} />
</TabsContent>
<TabsContent value="teams">
<TeamsPage />
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,21 +0,0 @@
import { getI18n } from "@/i18n/server"
import TeamCreateForm from "../../_components/team/team.create.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.list.addLabel}</h1>
</div>
<TeamCreateForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</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>
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { exec } from "node:child_process"
import { exec } from "child_process"
import { NextResponse } from "next/server"
import { verifyUserRole } from "@/services/auth.service"
-15
View File
@@ -1,15 +0,0 @@
import Link from "next/link"
import { getI18n } from "@/i18n/server"
export default async function ForbiddenPage() {
const { dictionary } = await getI18n()
const copy = dictionary.common.forbidden
return (
<main>
<h1>{copy.title}</h1>
<p>{copy.description}</p>
<Link href="/">{copy.homeLink}</Link>
</main>
)
}

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