Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd38621f8b | |||
| a0dcf85f5c | |||
| d3114326bb | |||
| 575900427a | |||
| 455e267999 | |||
| 7f607be01b | |||
| 5d303bdb7e | |||
| f88d831f4c | |||
| 65f9a6d6a4 | |||
| 8f6d2882f8 | |||
| b401f254ec | |||
| 2c03cd4d66 | |||
| de40e0bf73 | |||
| e4bd76a353 | |||
| 9b023ee558 | |||
| 44565e3a62 | |||
| 18e274ef37 | |||
| b4b63e107a | |||
| 91dc0220ae | |||
| 1142855578 | |||
| a0a1e1bdc8 | |||
| 8f7a406e83 | |||
| 95c52579d1 | |||
| 575cd2d9a0 | |||
| 0d38626f3a | |||
| d217edd4e5 | |||
| f32d55a7b0 | |||
| c1763ed007 | |||
| 965a04a468 | |||
| 7b8a415c6a | |||
| c123170a46 | |||
| 6d34a2f74f | |||
| 8e6a00c2a9 | |||
| 01d89cd21b | |||
| 2ed9445f7f | |||
| 1a95bf4613 | |||
| fff5ce01e5 | |||
| fb5f3da7c1 | |||
| df02d24d79 | |||
| 92f83c708a | |||
| d6b42d78e7 | |||
| 4f370eee70 | |||
| 1f5a849bf5 | |||
| e5717461cf | |||
| 68c2983d36 | |||
| caf19575c6 | |||
| cf6820a7aa | |||
| 29c7c19cd8 | |||
| ecc3cf1b55 | |||
| d67f31cf54 | |||
| befe1f3f82 | |||
| 20552ba68b | |||
| 73552dbb05 | |||
| 0cbbe60299 | |||
| 349559f4e0 | |||
| bfea2b77ab | |||
| 9b713c42e2 | |||
| c0ae7a034a | |||
| ea37fc8d70 | |||
| f62cd6fb37 | |||
| 7d5ab64653 | |||
| 3d6b13dc1c | |||
| c67e86c91b | |||
| 964b1648ca | |||
| 9f7d1b8ef8 | |||
| e9a07eb28e | |||
| 589b042e7c | |||
| 2fa6611719 | |||
| c3cf4182ad | |||
| 18a192a069 | |||
| ac3dfe69cd | |||
| 2c6d6bffcd | |||
| f2b9239d82 | |||
| cb01f4f8ef | |||
| bc5926a5de | |||
| 16a68e01ab | |||
| a7f7ace527 | |||
| da9ae0582b | |||
| 24d2d59bbc | |||
| f48ccb8c50 | |||
| 0af25417ab | |||
| 2b908b24f6 | |||
| e88fb2e6d4 | |||
| 5034ec0646 | |||
| 12cbec92a0 | |||
| 601dea9526 | |||
| 9ecb543c18 | |||
| 72973bfb3f | |||
| 4b40f50e7f | |||
| feae1d2cda | |||
| 0d877cbba6 | |||
| eb07760748 | |||
| d748e3e6c5 | |||
| 9c7e987d6e | |||
| e75cd424e3 | |||
| 1ec992caf6 | |||
| bb0948f590 | |||
| 6f16d26a8e | |||
| a7b547a92d | |||
| d60801e6c2 | |||
| 51e7a98d3f | |||
| fab2ba8835 | |||
| 5bb5223cd9 | |||
| c25a8e0da3 | |||
| 5ac2dc5277 | |||
| fd18692110 | |||
| f5c759fc3a | |||
| b2fc8b83ad | |||
| ba7e650c70 | |||
| 1bf6729d52 |
@@ -10,10 +10,7 @@
|
||||
// "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.
|
||||
@@ -23,14 +20,13 @@
|
||||
"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"
|
||||
"csstools.postcss",
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -39,4 +35,4 @@
|
||||
"containerEnv": {
|
||||
"SHELL": "/bin/bash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -13,4 +13,11 @@ NODE_ENV=production
|
||||
DEMO_MODE=false
|
||||
DOMAIN=localhost
|
||||
AUTH_TRUST_HOST="http://localhost"
|
||||
AUTH_SECRET=your_secret_key_here
|
||||
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
|
||||
+14
@@ -12,11 +12,16 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# prisma
|
||||
src/generated
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -39,3 +44,12 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# vscode
|
||||
!.vscode
|
||||
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.pi/
|
||||
openspec/
|
||||
sdd/
|
||||
@@ -1,15 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.husky
|
||||
coverage
|
||||
.prettierignore
|
||||
.stylelintignore
|
||||
.eslintignore
|
||||
stories
|
||||
storybook-static
|
||||
*.log
|
||||
playwright-report
|
||||
.nyc_output
|
||||
test-results
|
||||
junit.xml
|
||||
docs
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"trailingComma": "all",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"endOfLine": "auto",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
+14
-19
@@ -1,20 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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
@@ -39,4 +39,4 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
CMD ["sh", "-c", "bun run db:deploy && bun run db:seed && bun run start"]
|
||||
@@ -1,238 +1,406 @@
|
||||
# Stock Manager Home
|
||||
|
||||
Sistema de gestión de inventario y asignación de activos desarrollado con Next.js 15, Prisma, PostgreSQL y NextAuth.
|
||||
Sistema de gestión de inventario, activos serializados, asignaciones y movimientos construido con Next.js, Prisma, PostgreSQL, NextAuth y Bun.
|
||||
|
||||
## 📋 Descripción
|
||||
|
||||
Stock Manager es una aplicación web completa para la gestión de inventarios, activos y asignaciones de equipamiento. Permite controlar tanto ítems genéricos (gestionados por cantidad) como activos serializados (con número de serie único), registrar movimientos de stock, y gestionar asignaciones a destinatarios por departamentos.
|
||||
|
||||
## ✨ Características principales
|
||||
|
||||
### Gestión de Inventario
|
||||
|
||||
- **Ítems genéricos**: Productos sin número de serie, gestionados por cantidad en stock
|
||||
- **Activos serializados**: Equipos individuales con número de serie único
|
||||
- **Categorías**: Organización jerárquica de productos
|
||||
- **Control de stock**: Niveles mínimos/máximos, alertas de stock bajo (WIP)
|
||||
- **Estados múltiples**: Disponible, Asignado, Reservado, En reparación, Averiado, Robado, Dado de baja
|
||||
|
||||
### Gestión de Destinatarios
|
||||
|
||||
- Registro de empleados/destinatarios por departamento
|
||||
- Información de contacto (email, teléfono)
|
||||
- Historial de asignaciones por destinatario
|
||||
|
||||
### Sistema de Asignaciones
|
||||
|
||||
- Asignación de ítems genéricos (por cantidad)
|
||||
- Asignación de activos individuales (uno a uno)
|
||||
- Seguimiento de fechas de asignación y devolución
|
||||
- Notas y detalles de cada asignación
|
||||
- Registro del usuario que realiza cada asignación
|
||||
|
||||
### Movimientos e Historial
|
||||
|
||||
- Registro completo de todos los movimientos de stock
|
||||
- Tipos de movimiento: IN, OUT, ASSIGNMENT, RETURN, ADJUSTMENT, DELETED
|
||||
- Trazabilidad completa con stock previo y nuevo
|
||||
- Auditoría de todos los cambios con usuario y fecha
|
||||
|
||||
### Importación de Datos
|
||||
|
||||
- Importación masiva vía CSV
|
||||
- Plantilla descargable para importaciones
|
||||
- Validación de datos en el proceso de importación
|
||||
|
||||
### Sistema de Autenticación y Roles
|
||||
|
||||
- Autenticación segura con NextAuth v5
|
||||
- 4 roles de usuario: ADMIN, MANAGER, STAFF, VIEWER
|
||||
- Permisos diferenciados según rol
|
||||
- Contraseñas hasheadas con bcrypt
|
||||
|
||||
## 🚀 Tecnologías
|
||||
|
||||
- **Framework**: Next.js 15 (App Router)
|
||||
- **Base de datos**: PostgreSQL 18
|
||||
- **ORM**: Prisma 6
|
||||
- **Autenticación**: NextAuth v5
|
||||
- **UI**: React 19, Tailwind CSS, Shadcn
|
||||
- **Validación**: Zod
|
||||
- **Formularios**: React Hook Form
|
||||
- **Runtime**: Bun (recomendado)
|
||||
- **Containerización**: Docker + Docker Compose
|
||||
|
||||
## 🔨 Desarrollo en entorno DevContainer
|
||||
|
||||
Este proyecto incluye configuración para desarrollo en contenedor usando [DevContainer](https://containers.dev/).
|
||||
|
||||
1. Abre el proyecto en VS Code y selecciona "Reopen in Container".
|
||||
2. El entorno instalará dependencias automáticamente (bun i) y lanzará el servidor de desarrollo (bun run dev).
|
||||
3. El puerto 3000 estará disponible para acceder a la app desde tu navegador.
|
||||
|
||||
## 🔨 Desarrollo local
|
||||
|
||||
### Prerrequisitos
|
||||
|
||||
- Node.js 18+ o Bun
|
||||
- PostgreSQL 13+ (o usar Docker Compose)
|
||||
- Git
|
||||
|
||||
1. Clonar el repositorio:
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd stock-manager
|
||||
```
|
||||
|
||||
2. Instalar dependencias:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
bun install
|
||||
# o
|
||||
npm install
|
||||
cp .env.example .env
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
bun run db:seed
|
||||
bun run dev
|
||||
```
|
||||
|
||||
3. Configurar variables de entorno:
|
||||
Abrí la aplicación en [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
> `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.
|
||||
|
||||
## Qué hace la aplicación
|
||||
|
||||
Stock Manager permite gestionar:
|
||||
|
||||
- **Í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.
|
||||
|
||||
## Stack técnico
|
||||
|
||||
| Á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 |
|
||||
|
||||
## Internacionalización (i18n)
|
||||
|
||||
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.
|
||||
|
||||
Superficies localizadas:
|
||||
|
||||
- **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.
|
||||
|
||||
La arquitectura i18n sigue un patrón consistente:
|
||||
|
||||
- **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.
|
||||
|
||||
La importación CSV queda fuera del alcance actual de i18n por su rediseño previsto.
|
||||
|
||||
## Configuración de entorno
|
||||
|
||||
Copiá el ejemplo y completá los valores reales:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Editar `.env` con tus configuraciones:
|
||||
Variables principales:
|
||||
|
||||
```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
|
||||
| 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` |
|
||||
|
||||
# NextAuth
|
||||
NODE_ENV=development
|
||||
DEMO_MODE=false
|
||||
AUTH_SECRET="your-secret-key-here"
|
||||
AUTH_TRUST_HOST=true
|
||||
DOMAIN=localhost:3000
|
||||
```
|
||||
### Bootstrap admin
|
||||
|
||||
4. Ejecutar migraciones de base de datos:
|
||||
El seed ejecuta `prisma/seed.ts`, que llama a `prisma/bootstrap-admin.ts`.
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
# o generar el cliente Prisma
|
||||
bun run db:generate
|
||||
```
|
||||
Comportamiento:
|
||||
|
||||
5. (Opcional) Ejecutar seed para datos iniciales:
|
||||
- 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
|
||||
```
|
||||
|
||||
6. Iniciar el servidor de desarrollo:
|
||||
## Desarrollo local
|
||||
|
||||
### Prerrequisitos
|
||||
|
||||
- Bun 1.3+
|
||||
- PostgreSQL accesible mediante `DATABASE_URL`
|
||||
- Docker disponible para tests con Testcontainers
|
||||
- Git
|
||||
|
||||
### Pasos
|
||||
|
||||
```bash
|
||||
# 1. Instalar dependencias
|
||||
bun install
|
||||
|
||||
# 2. Configurar entorno
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Generar cliente Prisma
|
||||
bun run db:generate
|
||||
|
||||
# 4. Aplicar migraciones en desarrollo
|
||||
bun run db:migrate
|
||||
|
||||
# 5. Crear admin inicial, si corresponde
|
||||
bun run db:seed
|
||||
|
||||
# 6. Levantar Next
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Abrir [http://localhost:3000](http://localhost:3000) en el navegador.
|
||||
## Desarrollo con DevContainer
|
||||
|
||||
## 🐳 Despliegue con Docker
|
||||
El proyecto incluye configuración para desarrollo en contenedor.
|
||||
|
||||
### Producción
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.yaml up -d
|
||||
docker compose -f compose.yaml up -d
|
||||
```
|
||||
|
||||
Con Traefik (reverse proxy):
|
||||
El `Dockerfile` ejecuta al iniciar:
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.yaml -f compose.traefik.yaml up -d
|
||||
bun run db:deploy && bun run db:seed && bun run start
|
||||
```
|
||||
|
||||
## 📜 Scripts disponibles
|
||||
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á:
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
bun run dev # Inicia servidor de desarrollo con Turbopack
|
||||
bun run build # Construye para producción
|
||||
bun run start # Inicia servidor de producción
|
||||
|
||||
# Linting y formato
|
||||
bun run lint # Ejecuta ESLint
|
||||
bun run lint:fix # Corrige errores de ESLint automáticamente
|
||||
bun run format # Formatea código con Prettier
|
||||
|
||||
# Base de datos
|
||||
bun run db:push # Sincroniza schema sin migraciones
|
||||
bun run db:migrate # Crea y ejecuta migraciones
|
||||
bun run db:migrate:reset # Resetea BD y ejecuta migraciones
|
||||
bun run db:deploy # Ejecuta migraciones en producción
|
||||
bun run db:generate # Genera cliente Prisma
|
||||
bun run db:studio # Abre Prisma Studio (GUI para BD)
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
## 📁 Estructura del proyecto
|
||||
Validar schema:
|
||||
|
||||
```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/
|
||||
├── 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
|
||||
├── 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
|
||||
```
|
||||
|
||||
## 🔐 Seguridad
|
||||
## Autenticación y autorizació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
|
||||
- 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.
|
||||
|
||||
## 🗃️ Modelo de datos
|
||||
Helpers relevantes:
|
||||
|
||||
El sistema gestiona las siguientes entidades principales:
|
||||
```txt
|
||||
src/services/auth.service.ts
|
||||
src/lib/auth.ts
|
||||
src/proxy.ts
|
||||
```
|
||||
|
||||
- **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
|
||||
## Modelo de datos
|
||||
|
||||
Ver `src/prisma/schema.prisma` para el esquema completo.
|
||||
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.
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
|
||||
@@ -37,6 +37,10 @@ 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
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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[]
|
||||
@@ -2,12 +2,6 @@ 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
|
||||
|
||||
+37
-33
@@ -2,29 +2,33 @@
|
||||
"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",
|
||||
"next-lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"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",
|
||||
"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": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@@ -36,40 +40,40 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.518.0",
|
||||
"next": "15.3.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.4",
|
||||
"next-auth": "^5.0.0-beta.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"sonner": "^2.0.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"testcontainers": "^12.0.1",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@prisma/client",
|
||||
@@ -79,4 +83,4 @@
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
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"),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
-- 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');
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,593 @@
|
||||
// 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
|
||||
// ======================================================
|
||||
|
||||
enum PersonDepartment {
|
||||
IT
|
||||
ENGINEERING
|
||||
LOGISTICS
|
||||
TRAFFIC
|
||||
DRIVER
|
||||
ADMINISTRATION
|
||||
SALES
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Person {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
firstName String
|
||||
lastName String
|
||||
department PersonDepartment?
|
||||
|
||||
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([department, deletedAt])
|
||||
@@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])
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"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 result = await createAssignmentUseCase({
|
||||
...validatedFields.data,
|
||||
lines: [
|
||||
{
|
||||
itemId: validatedFields.data.itemId!,
|
||||
quantity: validatedFields.data.quantity!,
|
||||
notes: validatedFields.data.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 result = await updateAssignmentUseCase({
|
||||
...validatedFields.data,
|
||||
lines: [
|
||||
{
|
||||
itemId: validatedFields.data.itemId!,
|
||||
quantity: validatedFields.data.quantity!,
|
||||
notes: validatedFields.data.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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
import { AuthError } from "next-auth"
|
||||
|
||||
import { signIn } from "@/lib/auth"
|
||||
import { SignInFormType } from "@/lib/schemas/auth.schemas"
|
||||
import type { SignInFormType } from "@/schemas/auth.schema"
|
||||
|
||||
export async function signInAction(values: SignInFormType) {
|
||||
const { username, password } = values
|
||||
const { email, password } = values
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
"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: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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 }
|
||||
}
|
||||
@@ -2,26 +2,37 @@
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Papa from "papaparse"
|
||||
import { flattenError } from "zod"
|
||||
|
||||
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas"
|
||||
import { ImportItem } from "@/lib/types"
|
||||
import { type ImportFormType, importSchema } from "@/schemas/import.schema"
|
||||
import type { CreateMovementFormType } from "@/schemas/movement.schema"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
import { getAuthenticatedUserId } from "@/services/auth.service"
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { MovementService } from "@/services/movement.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import type {
|
||||
Asset,
|
||||
Assignment,
|
||||
Category,
|
||||
ImportItem,
|
||||
Item,
|
||||
Person,
|
||||
} from "@/types"
|
||||
|
||||
export async function importItems(formData: ImportFormType) {
|
||||
const validatedFields = importSchema.safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { file, categoryId } = validatedFields.data
|
||||
const userId = await getAuthenticatedUserId()
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
@@ -47,7 +58,7 @@ export async function importItems(formData: ImportFormType) {
|
||||
if (papaErrors.length > 0) {
|
||||
return {
|
||||
errors: {
|
||||
file: papaErrors.map((err) => err.message).flat(),
|
||||
file: papaErrors.flatMap((err) => err.message),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -142,7 +153,6 @@ export async function importItems(formData: ImportFormType) {
|
||||
category,
|
||||
deliveryNote,
|
||||
assigned,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
} = row
|
||||
@@ -155,7 +165,7 @@ export async function importItems(formData: ImportFormType) {
|
||||
importErrors.push(`Row ${index + 2}: Category or categoryId is required`)
|
||||
}
|
||||
|
||||
if (stock && isNaN(Number(stock))) {
|
||||
if (stock && Number.isNaN(stock)) {
|
||||
importErrors.push(`Row ${index + 2}: Stock must be a number`)
|
||||
}
|
||||
|
||||
@@ -167,10 +177,6 @@ export async function importItems(formData: ImportFormType) {
|
||||
importErrors.push(`Row ${index + 2}: Delivery note must be a string`)
|
||||
}
|
||||
|
||||
if (username && typeof username !== "string") {
|
||||
importErrors.push(`Row ${index + 2}: Username must be a string`)
|
||||
}
|
||||
|
||||
if (firstName && typeof firstName !== "string") {
|
||||
importErrors.push(`Row ${index + 2}: First name must be a string`)
|
||||
}
|
||||
@@ -202,8 +208,7 @@ export async function importItems(formData: ImportFormType) {
|
||||
categoryId: categoryId ? categoryId : row.categoryId?.trim() || "",
|
||||
category: row.category?.trim() || "",
|
||||
deliveryNote: row.deliveryNote?.trim() || "",
|
||||
assigned: row.assigned?.trim() === "true" ? true : false,
|
||||
username: row.username?.trim() || "",
|
||||
assigned: row.assigned?.trim() === "true",
|
||||
firstName: row.firstName?.trim() || "",
|
||||
lastName: row.lastName?.trim() || "",
|
||||
})
|
||||
@@ -218,17 +223,16 @@ export async function importItems(formData: ImportFormType) {
|
||||
category,
|
||||
deliveryNote,
|
||||
assigned,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
} = item
|
||||
|
||||
// Reset variables at the beginning of each iteration
|
||||
let newItem
|
||||
let newAsset
|
||||
let newCategory
|
||||
let newRecipient
|
||||
let newAssignment
|
||||
let newItem: Item | null = null
|
||||
let newAsset: Asset | null = null
|
||||
let newCategory: Category | null = null
|
||||
let newPerson: Person | null = null
|
||||
let newAssignment: Assignment | null = null
|
||||
|
||||
const existingCategory = categoryId
|
||||
? await CategoryService.findById(categoryId)
|
||||
@@ -244,7 +248,13 @@ export async function importItems(formData: ImportFormType) {
|
||||
|
||||
if (!existingItem) {
|
||||
newItem = await ItemService.create({
|
||||
sku: name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, ""),
|
||||
name,
|
||||
trackingType: "QUANTITY",
|
||||
stock: assigned ? 0 : stock || 0,
|
||||
category: {
|
||||
connect: { id: categoryId ? categoryId : newCategory?.id || "" },
|
||||
@@ -279,14 +289,16 @@ export async function importItems(formData: ImportFormType) {
|
||||
}
|
||||
|
||||
if (assigned && firstName && lastName) {
|
||||
const finalUsername =
|
||||
username || `${firstName.toLowerCase()[0]}${lastName.toLowerCase()}`
|
||||
const existingRecipient =
|
||||
await RecipientService.findByUsername(finalUsername)
|
||||
const existingPerson = firstName
|
||||
? await PersonService.findAllPaginated({
|
||||
search: firstName,
|
||||
page: 0,
|
||||
pageSize: 1,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!existingRecipient) {
|
||||
newRecipient = await RecipientService.create({
|
||||
username: finalUsername,
|
||||
if (!existingPerson || existingPerson.data.length === 0) {
|
||||
newPerson = await PersonService.create({
|
||||
firstName,
|
||||
lastName,
|
||||
email: undefined,
|
||||
@@ -294,7 +306,7 @@ export async function importItems(formData: ImportFormType) {
|
||||
department: "OTHER",
|
||||
})
|
||||
} else {
|
||||
newRecipient = existingRecipient
|
||||
newPerson = existingPerson.data[0]
|
||||
}
|
||||
|
||||
newAssignment = await AssignmentService.create({
|
||||
@@ -302,28 +314,32 @@ export async function importItems(formData: ImportFormType) {
|
||||
notes: deliveryNote || "",
|
||||
itemId: newItem?.id || "",
|
||||
assetId: newAsset?.id || "",
|
||||
recipientId: newRecipient?.id || "",
|
||||
personId: newPerson?.id || "",
|
||||
assignmentDate: new Date(),
|
||||
createdBy: userId,
|
||||
})
|
||||
}
|
||||
|
||||
const movementData: any = {
|
||||
const movementData: CreateMovementFormType = {
|
||||
assetId: newAsset?.id || undefined,
|
||||
quantity: stock || 1,
|
||||
type: assigned ? "ASSIGNMENT" : "IN",
|
||||
itemId: newItem?.id || undefined,
|
||||
recipientId: newRecipient?.id || undefined,
|
||||
itemId: assigned ? undefined : newItem?.id || undefined,
|
||||
personId: newPerson?.id || undefined,
|
||||
}
|
||||
|
||||
if (newAssignment?.id) {
|
||||
movementData.assignmentId = newAssignment.id
|
||||
}
|
||||
|
||||
if (newRecipient?.id) {
|
||||
movementData.recipientId = newRecipient.id
|
||||
if (newPerson?.id) {
|
||||
movementData.personId = newPerson.id
|
||||
}
|
||||
|
||||
await MovementService.create(movementData)
|
||||
await MovementService.create({
|
||||
...movementData,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/inventory/items")
|
||||
@@ -0,0 +1,135 @@
|
||||
"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: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
type PersonActionCopy = Dictionary["inventory"]["people"]["actions"]
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
const personErrorMessageKeys = {
|
||||
"Email already exists": "duplicateEmail",
|
||||
} 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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"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()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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 === "department" && message === schemaCopy.departmentRequired)
|
||||
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)
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,16 @@ 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 { signInAction } from "@/lib/actions/auth.actions"
|
||||
import { SignInFormType, signInSchema } from "@/lib/schemas/auth.schemas"
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import { type SignInFormType, signInSchema } from "@/schemas/auth.schema"
|
||||
|
||||
export default function SignInForm() {
|
||||
type SignInFormProps = {
|
||||
copy: Dictionary["login"]
|
||||
}
|
||||
|
||||
export default function SignInForm({ copy }: SignInFormProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const callbackUrl = searchParams.get("callbackUrl")
|
||||
@@ -18,7 +22,7 @@ export default function SignInForm() {
|
||||
const { register, handleSubmit, formState } = useForm<SignInFormType>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
@@ -38,19 +42,19 @@ export default function SignInForm() {
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<label className="flex flex-col gap-1">
|
||||
Username
|
||||
{copy.emailLabel}
|
||||
<input
|
||||
{...register("username")}
|
||||
name="username"
|
||||
{...register("email")}
|
||||
name="email"
|
||||
type="text"
|
||||
className="border-input w-full rounded-md border-2 p-2"
|
||||
/>
|
||||
{formState.errors.username && (
|
||||
<p className="text-error">{formState.errors.username.message}</p>
|
||||
{formState.errors.email && (
|
||||
<p className="text-error">{formState.errors.email.message}</p>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Password
|
||||
{copy.passwordLabel}
|
||||
<input
|
||||
{...register("password")}
|
||||
name="password"
|
||||
@@ -62,7 +66,7 @@ export default function SignInForm() {
|
||||
)}
|
||||
</label>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
<Button type="submit">Sign In</Button>
|
||||
<Button type="submit">{copy.submitLabel}</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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"
|
||||
@@ -10,15 +12,26 @@ 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">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher
|
||||
activeLocale={locale}
|
||||
copy={dictionary.common.languageSwitcher}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardTitle>
|
||||
<h1>{copy.title}</h1>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignInForm />
|
||||
<SignInForm copy={copy} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ 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
|
||||
}) {
|
||||
@@ -18,7 +20,9 @@ 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">Total: {total}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{countLabel}: {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.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 totalRecipients = await RecipientService.findAllRecipientsCount()
|
||||
const totalPeople = await PersonService.findAllPeopleCount()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
|
||||
<h1 className="mb-4 text-2xl font-bold">{copy.heading}</h1>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card
|
||||
title="Total Items"
|
||||
title={copy.cards.items.title}
|
||||
total={totalItems}
|
||||
countLabel={copy.cards.items.countLabel}
|
||||
href="/inventory/items"
|
||||
icon={
|
||||
<svg
|
||||
@@ -24,6 +28,8 @@ export default async function Home() {
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="total-items"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -35,8 +41,9 @@ export default async function Home() {
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Total Assets"
|
||||
title={copy.cards.assets.title}
|
||||
total={totalAssets}
|
||||
countLabel={copy.cards.assets.countLabel}
|
||||
href="/inventory/assets"
|
||||
icon={
|
||||
<svg
|
||||
@@ -45,6 +52,8 @@ export default async function Home() {
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="total-assets"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -56,9 +65,10 @@ export default async function Home() {
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
title="Total Recipients"
|
||||
total={totalRecipients}
|
||||
href="/recipients"
|
||||
title={copy.cards.people.title}
|
||||
total={totalPeople}
|
||||
countLabel={copy.cards.people.countLabel}
|
||||
href="/people"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -66,6 +76,8 @@ export default async function Home() {
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="total-people"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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,39 +2,57 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateAssignment } from "@/lib/actions/assignament.actions"
|
||||
import { updateAssignment } from "@/actions/assignment.actions"
|
||||
import {
|
||||
UpdateAssignmentFormType,
|
||||
updateAssignmentSchema,
|
||||
} from "@/lib/schemas/assignment.schemas"
|
||||
import { Asset, Item, Recipient } from "@/lib/types"
|
||||
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"]
|
||||
|
||||
interface Props {
|
||||
recipients: Recipient[]
|
||||
people: Person[]
|
||||
items: Item[]
|
||||
assets: Asset[]
|
||||
initialData: UpdateAssignmentFormType
|
||||
formCopy: AssignmentFormCopy
|
||||
schemaCopy: AssignmentSchemaCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}
|
||||
|
||||
export default function EditAssignmentForm({
|
||||
recipients,
|
||||
people,
|
||||
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(updateAssignmentSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
...initialData,
|
||||
id: initialData.id || undefined,
|
||||
@@ -51,7 +69,7 @@ export default function EditAssignmentForm({
|
||||
if (response?.errors) {
|
||||
Object.values(response.errors as Record<string, string[]>).forEach(
|
||||
(messages) => {
|
||||
messages.forEach((msg) => toast.error(msg))
|
||||
messages.forEach((msg) => void toast.error(msg))
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -66,29 +84,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="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
<label htmlFor="personId" className="mb-2 block text-lg">
|
||||
{formCopy.personLabel}
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
id="personId"
|
||||
{...register("personId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.recipientId ? "border-error" : ""
|
||||
errors.personId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
{people.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.firstName} {person.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
{errors.personId && (
|
||||
<p className="text-error">{errors.personId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
Item
|
||||
{formCopy.itemLabel}
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
@@ -107,7 +125,7 @@ export default function EditAssignmentForm({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="assetId" className="mb-2 block text-lg">
|
||||
Asset
|
||||
{formCopy.assetLabel}
|
||||
</label>
|
||||
<select
|
||||
id="assetId"
|
||||
@@ -116,7 +134,7 @@ export default function EditAssignmentForm({
|
||||
errors.assetId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an asset</option>
|
||||
<option value="">{formCopy.assetPlaceholder}</option>
|
||||
{itemId
|
||||
? assets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
@@ -131,7 +149,7 @@ export default function EditAssignmentForm({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
Quantity
|
||||
{formCopy.quantityLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -139,6 +157,7 @@ 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 ${
|
||||
@@ -150,11 +169,12 @@ export default function EditAssignmentForm({
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!itemId || (assets.length > 0 && !assetId)}
|
||||
>
|
||||
Update Assignment
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,40 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createAssignment } from "@/lib/actions/assignament.actions"
|
||||
import { createAssignment } from "@/actions/assignment.actions"
|
||||
import {
|
||||
CreateAssignmentFormType,
|
||||
createAssignmentSchema,
|
||||
} from "@/lib/schemas/assignment.schemas"
|
||||
import { Asset, Item, Recipient } from "@/lib/types"
|
||||
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"]
|
||||
|
||||
interface Props {
|
||||
recipients: Recipient[]
|
||||
people: Person[]
|
||||
items: Item[]
|
||||
assets: Asset[]
|
||||
formCopy: AssignmentFormCopy
|
||||
schemaCopy: AssignmentSchemaCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}
|
||||
|
||||
export default function CreateAssignmentForm({
|
||||
recipients,
|
||||
people,
|
||||
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(createAssignmentSchema),
|
||||
resolver: zodResolver(schema),
|
||||
mode: "onSubmit",
|
||||
defaultValues: {
|
||||
personId: personId ?? "",
|
||||
},
|
||||
})
|
||||
|
||||
const itemId = watch("itemId")
|
||||
@@ -50,7 +72,7 @@ export default function CreateAssignmentForm({
|
||||
if (response?.errors) {
|
||||
Object.values(response.errors as Record<string, string[]>).forEach(
|
||||
(messages) => {
|
||||
messages.forEach((msg) => toast.error(msg))
|
||||
messages.forEach((msg) => void toast.error(msg))
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -64,30 +86,31 @@ export default function CreateAssignmentForm({
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
<label htmlFor="personId" className="mb-2 block text-lg">
|
||||
{formCopy.personLabel}
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
id="personId"
|
||||
disabled={!!personId}
|
||||
{...register("personId")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.recipientId ? "border-error" : ""
|
||||
errors.personId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select a recipient</option>
|
||||
{recipients.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
<option value="">{formCopy.personPlaceholder}</option>
|
||||
{people.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.firstName} {person.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
{errors.personId && (
|
||||
<p className="text-error">{errors.personId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
Item
|
||||
{formCopy.itemLabel}
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
@@ -96,7 +119,7 @@ export default function CreateAssignmentForm({
|
||||
errors.itemId ? "border-error" : ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an item</option>
|
||||
<option value="">{formCopy.itemPlaceholder}</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -108,7 +131,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">
|
||||
Asset
|
||||
{formCopy.assetLabel}
|
||||
</label>
|
||||
<select
|
||||
id="assetId"
|
||||
@@ -120,7 +143,7 @@ export default function CreateAssignmentForm({
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<option value="">Select an asset</option>
|
||||
<option value="">{formCopy.assetPlaceholder}</option>
|
||||
{itemId
|
||||
? itemAssets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
@@ -136,7 +159,7 @@ export default function CreateAssignmentForm({
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="quantity" className="mb-2 block text-lg">
|
||||
Quantity
|
||||
{formCopy.quantityLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -144,6 +167,7 @@ export default function CreateAssignmentForm({
|
||||
disabled={!itemId || itemAssets.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 ${
|
||||
@@ -157,11 +181,12 @@ export default function CreateAssignmentForm({
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!itemId || (itemAssets.length > 0 && !assetId)}
|
||||
>
|
||||
Create Assignment
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -2,51 +2,209 @@
|
||||
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { useState, useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { returnAssignment } from "@/actions/assignment.actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { returnAssignment } from "@/lib/actions/assignament.actions"
|
||||
import { ReturnAssignmentFormType } from "@/lib/schemas/assignment.schemas"
|
||||
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",
|
||||
}
|
||||
|
||||
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 && response.errors?.id) {
|
||||
toast.error(response.errors.id[0])
|
||||
if (response.success) {
|
||||
setOpen(false)
|
||||
setQuantity(1)
|
||||
setNotes("")
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
if (response.errors?.error?.includes("errorConcurrent")) {
|
||||
setErrorKey("errorConcurrent")
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
setErrorKey("errorGeneric")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={() => handleReturn({ id: assignmentId })} className="w-full">
|
||||
<input type="hidden" name="id" value={assignmentId} />
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import AssignmentForm from "../_components/new.assignment.form"
|
||||
|
||||
export default async function NewAssignmentPage() {
|
||||
const recipients = await RecipientService.findAll()
|
||||
const people = await PersonService.findAll()
|
||||
const items = await ItemService.findAllWithStock()
|
||||
const assets = await AssetService.findAllAvailable()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.assignments
|
||||
|
||||
return (
|
||||
<AssignmentForm recipients={recipients} items={items} assets={assets} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -15,39 +16,45 @@ export default async function AssignmentsPage(props: {
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: assignments, totalPages } =
|
||||
await AssignmentService.findAllWithRecipientPaginated({
|
||||
await AssignmentService.findAllWithPersonPaginated({
|
||||
page: currentPage,
|
||||
search,
|
||||
})
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.assignments
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Assignments"
|
||||
title={copy.list.title}
|
||||
link="/assignments/new"
|
||||
search={search}
|
||||
data={assignments}
|
||||
addLabel={copy.list.addLabel}
|
||||
/>
|
||||
{assignments.length === 0 && <div>No assignments found</div>}
|
||||
{assignments.length === 0 && <div>{copy.list.empty}</div>}
|
||||
{assignments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Recipient
|
||||
{copy.list.columns.person}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Item
|
||||
{copy.list.columns.item}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
{copy.list.columns.serialNumber}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
{copy.list.columns.quantity}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -56,11 +63,11 @@ export default async function AssignmentsPage(props: {
|
||||
<tr key={assignment.id} className="border-b">
|
||||
<td className="p-4">
|
||||
<Link
|
||||
href={`/recipients/${assignment?.recipient?.id}`}
|
||||
href={`/people/${assignment?.person?.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{assignment?.recipient?.firstName}{" "}
|
||||
{assignment?.recipient?.lastName}
|
||||
{assignment?.person?.firstName}{" "}
|
||||
{assignment?.person?.lastName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
@@ -72,7 +79,19 @@ export default async function AssignmentsPage(props: {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{assignment?.asset?.serialNumber || "N/A"}
|
||||
{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}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2">
|
||||
@@ -80,11 +99,20 @@ export default async function AssignmentsPage(props: {
|
||||
href={`/assignments/${assignment.id}/edit`}
|
||||
passHref
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
<ReturnButton assignmentId={assignment.id} />
|
||||
<ReturnButton
|
||||
assignmentId={assignment.id}
|
||||
ariaLabel={copy.list.actions.return}
|
||||
assignmentLineId={assignment.assignmentLineId}
|
||||
remainingQuantity={assignment.remainingQuantity}
|
||||
copy={copy.partialReturn}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -92,7 +120,7 @@ export default async function AssignmentsPage(props: {
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-sm">
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChangeEvent } from "react"
|
||||
import type { ChangeEvent } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { importItems } from "@/lib/actions/import.actions"
|
||||
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
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"
|
||||
|
||||
export default function ImportForm({
|
||||
categories,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -96,6 +100,7 @@ export default function ImportForm({
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
disabled={!file}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
|
||||
@@ -9,13 +10,14 @@ 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 === "demo" && (
|
||||
{(ENVIRONMENT === "development" || ENVIRONMENT === "demo") && (
|
||||
<Link href="/sample_data.csv" download>
|
||||
<Button variant="outline">
|
||||
<Download />
|
||||
@@ -30,7 +32,10 @@ export default async function ImportPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<ImportForm categories={categories} />
|
||||
<ImportForm
|
||||
categories={categories}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server"
|
||||
|
||||
import { AssetWithAssignment } from "@/lib/types"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import type { AssetWithAssignment } from "@/types"
|
||||
|
||||
import EditAssetForm from "../../_components/edit.asset.form"
|
||||
|
||||
@@ -14,22 +15,28 @@ export default async function EditAssetPage({
|
||||
}) {
|
||||
const { assetId } = await params
|
||||
const items = await ItemService.findAll()
|
||||
const recipients = await RecipientService.findAll()
|
||||
const people = await PersonService.findAll()
|
||||
const asset = await AssetService.findById(assetId)
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.assets
|
||||
|
||||
if (!asset) {
|
||||
return <div>Asset not found</div>
|
||||
return <div>{copy.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">Edit Asset</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
||||
</div>
|
||||
<EditAssetForm
|
||||
items={items}
|
||||
recipients={recipients}
|
||||
people={people}
|
||||
asset={asset as unknown as AssetWithAssignment}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
statusCopy={copy.status}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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,35 +2,53 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { ItemStatus } from "@/generated/prisma/client"
|
||||
import { updateAssetAction } from "@/lib/actions/asset.actions"
|
||||
import { updateAssetAction } from "@/actions/asset.actions"
|
||||
import {
|
||||
UpdateAssetFormType,
|
||||
updateAssetSchema,
|
||||
} from "@/lib/schemas/asset.schemas"
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
|
||||
import {
|
||||
buildUpdateAssetSchema,
|
||||
type UpdateAssetFormType,
|
||||
} from "@/schemas/asset.schema"
|
||||
import type {
|
||||
AssetWithAssignment,
|
||||
Item,
|
||||
Recipient,
|
||||
Person,
|
||||
UpdateAssetStatus,
|
||||
} from "@/lib/types"
|
||||
} from "@/types"
|
||||
|
||||
import type {
|
||||
AssetFormCopy,
|
||||
AssetSchemaCopy,
|
||||
AssetStatusCopy,
|
||||
} from "./asset.copy"
|
||||
|
||||
interface EditAssetFormProps {
|
||||
asset: AssetWithAssignment
|
||||
items: Item[]
|
||||
recipients: Recipient[]
|
||||
people: Person[]
|
||||
formCopy: AssetFormCopy
|
||||
schemaCopy: AssetSchemaCopy
|
||||
statusCopy: AssetStatusCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}
|
||||
|
||||
export default function EditAssetForm({
|
||||
asset,
|
||||
items,
|
||||
recipients,
|
||||
people,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
statusCopy,
|
||||
submitButtonCopy,
|
||||
}: EditAssetFormProps) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(() => buildUpdateAssetSchema(schemaCopy), [schemaCopy])
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -39,14 +57,14 @@ export default function EditAssetForm({
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<UpdateAssetFormType>({
|
||||
resolver: zodResolver(updateAssetSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: asset.id,
|
||||
itemId: asset.itemId ?? "",
|
||||
itemId: asset.itemId ?? undefined,
|
||||
serialNumber: asset.serialNumber,
|
||||
deliveryNote: asset.deliveryNote ?? "",
|
||||
deliveryNote: asset.deliveryNote ?? undefined,
|
||||
status: asset.status as UpdateAssetStatus,
|
||||
recipientId: asset.assignment?.recipientId ?? "",
|
||||
personId: asset.assignment?.personId ?? undefined,
|
||||
},
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
@@ -71,7 +89,7 @@ export default function EditAssetForm({
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success("Asset updated successfully")
|
||||
toast.success(response.message)
|
||||
router.push(`/inventory/assets`)
|
||||
}
|
||||
}
|
||||
@@ -79,15 +97,16 @@ export default function EditAssetForm({
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Item
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
{formCopy.itemLabel}
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
defaultValue={asset.itemId}
|
||||
{...register("itemId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a item:</option>
|
||||
<option value="">{formCopy.itemPlaceholder}</option>
|
||||
{items?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -100,12 +119,13 @@ export default function EditAssetForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serialNumber" className="mb-2 block text-lg">
|
||||
Serial Number
|
||||
{formCopy.serialNumberLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serialNumber"
|
||||
placeholder="Serial number"
|
||||
placeholder={formCopy.serialNumberPlaceholder}
|
||||
defaultValue={asset.serialNumber}
|
||||
{...register("serialNumber")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -113,14 +133,110 @@ 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">
|
||||
Delivery Note
|
||||
{formCopy.deliveryNoteLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryNote"
|
||||
placeholder="Delivery note"
|
||||
placeholder={formCopy.deliveryNotePlaceholder}
|
||||
defaultValue={asset.deliveryNote ?? undefined}
|
||||
{...register("deliveryNote")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -130,17 +246,18 @@ export default function EditAssetForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="mb-2 block text-lg">
|
||||
Status
|
||||
{formCopy.statusLabel}
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
defaultValue={asset.status}
|
||||
{...register("status")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a status</option>
|
||||
{Object.values(ItemStatus).map((status) => (
|
||||
<option value="">{formCopy.statusPlaceholder}</option>
|
||||
{UPDATE_ASSET_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
{statusCopy[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -150,31 +267,33 @@ export default function EditAssetForm({
|
||||
</div>
|
||||
{status === "ASSIGNED" && (
|
||||
<div>
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
<label htmlFor="personId" className="mb-2 block text-lg">
|
||||
{formCopy.personLabel}
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
id="personId"
|
||||
defaultValue={asset.assignment?.personId ?? undefined}
|
||||
{...register("personId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a Recipient</option>
|
||||
{recipients?.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
<option value="">{formCopy.personPlaceholder}</option>
|
||||
{people?.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.firstName} {person.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
{errors?.personId && (
|
||||
<p className="text-error">{errors.personId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Asset
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -2,25 +2,46 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { ItemStatus } from "@/generated/prisma/client"
|
||||
import { createAssetAction } from "@/lib/actions/asset.actions"
|
||||
import { createAssetAction } from "@/actions/asset.actions"
|
||||
import {
|
||||
CreateAssetFormType,
|
||||
createAssetSchema,
|
||||
} from "@/lib/schemas/asset.schemas"
|
||||
import { ItemWithoutStock, Recipient } from "@/lib/types"
|
||||
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"
|
||||
|
||||
interface NewAssetFormProps {
|
||||
items: ItemWithoutStock[]
|
||||
recipients: Recipient[]
|
||||
people: Person[]
|
||||
formCopy: AssetFormCopy
|
||||
schemaCopy: AssetSchemaCopy
|
||||
statusCopy: AssetStatusCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}
|
||||
|
||||
export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
export default function NewAssetForm({
|
||||
items,
|
||||
people,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
statusCopy,
|
||||
submitButtonCopy,
|
||||
}: NewAssetFormProps) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(() => buildCreateAssetSchema(schemaCopy), [schemaCopy])
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -29,7 +50,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
watch,
|
||||
} = useForm<CreateAssetFormType>({
|
||||
resolver: zodResolver(createAssetSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
@@ -56,7 +77,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success("Asset created successfully")
|
||||
toast.success(response.message)
|
||||
router.push(`/inventory/assets`)
|
||||
}
|
||||
}
|
||||
@@ -64,15 +85,15 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Item
|
||||
<label htmlFor="itemId" className="mb-2 block text-lg">
|
||||
{formCopy.itemLabel}
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
{...register("itemId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a item:</option>
|
||||
<option value="">{formCopy.itemPlaceholder}</option>
|
||||
{items?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -85,12 +106,12 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serialNumber" className="mb-2 block text-lg">
|
||||
Serial Number
|
||||
{formCopy.serialNumberLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serialNumber"
|
||||
placeholder="Serial number"
|
||||
placeholder={formCopy.serialNumberPlaceholder}
|
||||
{...register("serialNumber")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -98,14 +119,103 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
<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">
|
||||
Delivery Note
|
||||
{formCopy.deliveryNoteLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryNote"
|
||||
placeholder="Delivery note"
|
||||
placeholder={formCopy.deliveryNotePlaceholder}
|
||||
{...register("deliveryNote")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -115,17 +225,17 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="mb-2 block text-lg">
|
||||
Status
|
||||
{formCopy.statusLabel}
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
{...register("status")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a status</option>
|
||||
{Object.values(ItemStatus).map((status) => (
|
||||
<option value="">{formCopy.statusPlaceholder}</option>
|
||||
{CREATE_ASSET_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
{statusCopy[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -135,31 +245,32 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
|
||||
</div>
|
||||
{status === "ASSIGNED" && (
|
||||
<div>
|
||||
<label htmlFor="recipientId" className="mb-2 block text-lg">
|
||||
Recipient
|
||||
<label htmlFor="personId" className="mb-2 block text-lg">
|
||||
{formCopy.personLabel}
|
||||
</label>
|
||||
<select
|
||||
id="recipientId"
|
||||
{...register("recipientId")}
|
||||
id="personId"
|
||||
{...register("personId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a Recipient</option>
|
||||
{recipients?.map((recipient) => (
|
||||
<option key={recipient.id} value={recipient.id}>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
<option value="">{formCopy.personPlaceholder}</option>
|
||||
{people?.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.firstName} {person.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.recipientId && (
|
||||
<p className="text-error">{errors.recipientId.message}</p>
|
||||
{errors?.personId && (
|
||||
<p className="text-error">{errors.personId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Asset
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"use server"
|
||||
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import NewAssetForm from "../_components/new.asset.form"
|
||||
|
||||
export default async function NewAssetPage() {
|
||||
const items = await ItemService.findAllAssignable()
|
||||
const recipients = await RecipientService.findAll()
|
||||
const people = await PersonService.findAll()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.assets
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New Asset</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
||||
</div>
|
||||
<NewAssetForm items={items} recipients={recipients} />
|
||||
<NewAssetForm
|
||||
items={items}
|
||||
people={people}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
statusCopy={copy.status}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,32 @@ 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
|
||||
@@ -13,7 +37,7 @@ export default async function AssetsPage(props: {
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: assets, totalPages } =
|
||||
await AssetService.findAllWithItemAndCategory({
|
||||
@@ -21,19 +45,22 @@ 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="Assets"
|
||||
title={copy.list.title}
|
||||
link="/inventory/assets/new"
|
||||
data={assets}
|
||||
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">
|
||||
No Assets found.
|
||||
{copy.list.empty}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -43,19 +70,37 @@ export default async function AssetsPage(props: {
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Item Name
|
||||
{copy.list.columns.item}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Category
|
||||
{copy.list.columns.category}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
{copy.list.columns.serialNumber}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Status
|
||||
{copy.list.columns.assetTag}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
{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}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -65,10 +110,31 @@ 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.status}</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="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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -78,7 +144,7 @@ export default async function AssetsPage(props: {
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<td colSpan={11} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
|
||||
import EditCategoryForm from "../../_components/edit.category.form"
|
||||
@@ -11,6 +12,8 @@ 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()
|
||||
@@ -19,9 +22,14 @@ 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">Edit Category</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
||||
</div>
|
||||
<EditCategoryForm category={category} />
|
||||
<EditCategoryForm
|
||||
category={category}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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,14 +4,17 @@ 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 { deleteCategoryAction } from "@/lib/actions/category.actions"
|
||||
|
||||
import type { CategoryDeleteCopy } from "./category.copy"
|
||||
|
||||
export default function DeleteCategoryButton({
|
||||
categoryId,
|
||||
copy,
|
||||
}: {
|
||||
categoryId: string
|
||||
copy: CategoryDeleteCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
@@ -29,7 +32,7 @@ export default function DeleteCategoryButton({
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
toast.error(response.message ?? copy.unknownError)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -43,6 +46,7 @@ export default function DeleteCategoryButton({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
aria-label={isPending ? copy.pending : copy.label}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
|
||||
@@ -2,23 +2,37 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateCategoryAction } from "@/lib/actions/category.actions"
|
||||
import { updateCategoryAction } from "@/actions/category.actions"
|
||||
import {
|
||||
UpdateCategoryFormType,
|
||||
updateCategorySchema,
|
||||
} from "@/lib/schemas/category.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
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"
|
||||
|
||||
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,
|
||||
@@ -26,7 +40,7 @@ export default function EditCategoryForm({
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateCategoryFormType>({
|
||||
resolver: zodResolver(updateCategorySchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
@@ -60,12 +74,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">
|
||||
Name
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Category name"
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.name ? "border-error" : ""
|
||||
@@ -74,10 +88,11 @@ export default function EditCategoryForm({
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Category
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -2,18 +2,34 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createCategoryAction } from "@/lib/actions/category.actions"
|
||||
import { createCategoryAction } from "@/actions/category.actions"
|
||||
import {
|
||||
CreateCategoryFormType,
|
||||
createCategorySchema,
|
||||
} from "@/lib/schemas/category.schemas"
|
||||
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() {
|
||||
export default function NewCategoryForm({
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
formCopy: CategoryFormCopy
|
||||
schemaCopy: CategorySchemaCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(
|
||||
() => buildCreateCategorySchema(schemaCopy),
|
||||
[schemaCopy],
|
||||
)
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -21,7 +37,7 @@ export default function NewCategoryForm() {
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateCategoryFormType>({
|
||||
resolver: zodResolver(createCategorySchema),
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateCategoryFormType) => {
|
||||
@@ -50,12 +66,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">
|
||||
Name
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Category name"
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${
|
||||
errors.name ? "border-error" : ""
|
||||
@@ -64,10 +80,11 @@ export default function NewCategoryForm() {
|
||||
{errors.name && <p className="text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Category
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
|
||||
import NewCategoryForm from "../_components/new.category.form"
|
||||
|
||||
export default function NewCategoryPage() {
|
||||
export default async function NewCategoryPage() {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.categories
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New Category</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
||||
</div>
|
||||
<NewCategoryForm />
|
||||
<NewCategoryForm
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -15,7 +16,7 @@ export default async function Items(props: {
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: categories, totalPages } =
|
||||
await CategoryService.findAllWithItemsCountPaginated({
|
||||
@@ -23,18 +24,21 @@ 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="Categories"
|
||||
title={copy.list.title}
|
||||
addLabel={copy.list.addLabel}
|
||||
link="/inventory/categories/new"
|
||||
data={categories}
|
||||
/>
|
||||
{categories.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No Categories found.
|
||||
{copy.list.empty}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -44,13 +48,13 @@ export default async function Items(props: {
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
{copy.list.columns.name}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Items
|
||||
{copy.list.columns.items}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -68,12 +72,16 @@ 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} />
|
||||
<DeleteCategoryButton
|
||||
categoryId={category.id}
|
||||
copy={copy.delete}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { CategoryService } from "@/services/category.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
|
||||
@@ -11,22 +12,30 @@ 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>Item not found</div>
|
||||
return <div>{copy.edit.notFound}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{item?._count?.assets && item?._count.assets > 0 && (
|
||||
<div className="rounded-sm bg-red-100 p-4 text-red-800">
|
||||
<p>{`This item has already assets assigned to it.`}</p>
|
||||
<p>{copy.edit.hasAssetsWarning}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit Item</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
||||
</div>
|
||||
<UpdateItemForm categories={categories} item={item} />
|
||||
<UpdateItemForm
|
||||
categories={categories}
|
||||
item={item}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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"
|
||||
@@ -12,9 +14,12 @@ 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>Item not found</div>
|
||||
return <div>{copy.notFound}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -26,11 +31,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">Category</span>
|
||||
<span className="text-gray-600">{copy.labels.category}</span>
|
||||
<span>{item.category.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Stock</span>
|
||||
<span className="text-gray-600">{copy.labels.stock}</span>
|
||||
<span>{item.stock}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +79,7 @@ export default async function ItemPage({
|
||||
{movements?.length > 0 && (
|
||||
<Card className="rounded-sm shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Movements</CardTitle>
|
||||
<CardTitle>{movementCopy.snippet.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{movements.map((movement) => (
|
||||
@@ -83,11 +88,21 @@ 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">Type</span>
|
||||
<span>{movement.type}</span>
|
||||
<span className="text-gray-600">
|
||||
{movementCopy.snippet.labels.type}
|
||||
</span>
|
||||
<span>
|
||||
{formatMovementType(
|
||||
movement.type,
|
||||
movementCopy.types,
|
||||
movementCopy.fallback,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Quantity</span>
|
||||
<span className="text-gray-600">
|
||||
{movementCopy.snippet.labels.quantity}
|
||||
</span>
|
||||
<span>{movement.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,18 @@ 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"
|
||||
|
||||
export default function DeleteItemButton({ itemId }: { itemId: string }) {
|
||||
import type { ItemDeleteCopy } from "./item.copy"
|
||||
|
||||
export default function DeleteItemButton({
|
||||
itemId,
|
||||
copy,
|
||||
}: {
|
||||
itemId: string
|
||||
copy: ItemDeleteCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
@@ -25,7 +32,7 @@ export default function DeleteItemButton({ itemId }: { itemId: string }) {
|
||||
toast.success(response.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(response.message ?? "Unknown error")
|
||||
toast.error(response.message ?? copy.unknownError)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -39,6 +46,7 @@ export default function DeleteItemButton({ itemId }: { itemId: string }) {
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
aria-label={isPending ? copy.pending : copy.label}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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 }
|
||||
@@ -2,23 +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 { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { createItemAction } from "@/lib/actions/item.actions"
|
||||
import { createItemAction } from "@/actions/item.actions"
|
||||
import {
|
||||
CreateItemFormType,
|
||||
createItemSchema,
|
||||
} from "@/lib/schemas/item.schemas"
|
||||
import { CategorySummary } from "@/lib/types"
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import {
|
||||
buildCreateItemSchema,
|
||||
type CreateItemFormType,
|
||||
} from "@/schemas/item.schema"
|
||||
import type { CategorySummary } from "@/types"
|
||||
|
||||
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
|
||||
|
||||
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,
|
||||
@@ -26,7 +38,7 @@ export default function NewItemForm({
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateItemFormType>({
|
||||
resolver: zodResolver(createItemSchema),
|
||||
resolver: zodResolver(schema),
|
||||
shouldFocusError: true,
|
||||
mode: "onSubmit",
|
||||
})
|
||||
@@ -57,12 +69,12 @@ export default function NewItemForm({
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Item name"
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -70,14 +82,14 @@ export default function NewItemForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Category
|
||||
{formCopy.categoryLabel}
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
{...register("categoryId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="">{formCopy.categoryPlaceholder}</option>
|
||||
{categories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
@@ -90,13 +102,13 @@ export default function NewItemForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stock" className="mb-2 block text-lg">
|
||||
Stock
|
||||
{formCopy.stockLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
pattern="{[0-9]*}"
|
||||
placeholder="0"
|
||||
placeholder={formCopy.stockPlaceholder}
|
||||
min="0"
|
||||
{...register("stock")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
@@ -116,10 +128,11 @@ export default function NewItemForm({
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create Item
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -2,25 +2,37 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import { updateItemAction } from "@/lib/actions/item.actions"
|
||||
import { updateItemAction } from "@/actions/item.actions"
|
||||
import {
|
||||
UpdateItemFormType,
|
||||
updateItemSchema,
|
||||
} from "@/lib/schemas/item.schemas"
|
||||
import { CategorySummary, ItemWithAssetCount } from "@/lib/types"
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import {
|
||||
buildUpdateItemSchema,
|
||||
type UpdateItemFormType,
|
||||
} from "@/schemas/item.schema"
|
||||
import type { CategorySummary, ItemWithAssetCount } from "@/types"
|
||||
|
||||
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
|
||||
|
||||
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
|
||||
|
||||
@@ -30,7 +42,7 @@ export default function UpdateItemForm({
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateItemFormType>({
|
||||
resolver: zodResolver(updateItemSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
@@ -68,12 +80,12 @@ export default function UpdateItemForm({
|
||||
{item?.id && <input type="hidden" name="id" value={item.id} />}
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-lg">
|
||||
Name
|
||||
{formCopy.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="Item name"
|
||||
placeholder={formCopy.namePlaceholder}
|
||||
{...register("name")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
@@ -81,7 +93,7 @@ export default function UpdateItemForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="mb-2 block text-lg">
|
||||
Category
|
||||
{formCopy.categoryLabel}
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
@@ -89,7 +101,7 @@ export default function UpdateItemForm({
|
||||
{...register("categoryId")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="">{formCopy.categoryPlaceholder}</option>
|
||||
{categories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
@@ -102,13 +114,13 @@ export default function UpdateItemForm({
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stock" className="mb-2 block text-lg">
|
||||
Stock
|
||||
{formCopy.stockLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
pattern="{[0-9]*}"
|
||||
placeholder="0"
|
||||
placeholder={formCopy.stockPlaceholder}
|
||||
min={item.stock}
|
||||
disabled={isDisabled}
|
||||
{...register("stock")}
|
||||
@@ -131,10 +143,11 @@ export default function UpdateItemForm({
|
||||
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update Item
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
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">New Item</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
||||
</div>
|
||||
<NewItemForm categories={categories} />
|
||||
<NewItemForm
|
||||
categories={categories}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -15,26 +16,29 @@ export default async function ItemsPage(props: {
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 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="Items"
|
||||
title={copy.list.title}
|
||||
link="/inventory/items/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={items}
|
||||
search={search}
|
||||
/>
|
||||
{items.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No items found.
|
||||
{copy.list.empty}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -44,19 +48,19 @@ export default async function ItemsPage(props: {
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
{copy.list.columns.name}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Category
|
||||
{copy.list.columns.category}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Assets
|
||||
{copy.list.columns.assets}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Stock
|
||||
{copy.list.columns.stock}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -69,17 +73,25 @@ export default async function ItemsPage(props: {
|
||||
<td className="p-4">{item.stock}</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/inventory/items/${item.id}`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.view}
|
||||
>
|
||||
<Eye />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/inventory/items/${item.id}/edit`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={copy.list.actions.edit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
{item._count.assets === 0 && item.stock === 0 && (
|
||||
<DeleteItemButton itemId={item.id} />
|
||||
<DeleteItemButton itemId={item.id} copy={copy.delete} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,15 +3,18 @@ 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 />
|
||||
<AppSidebar copy={dictionary.layout.sidebar} />
|
||||
<main className="w-full">
|
||||
<Navbar />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
}
|
||||
@@ -1,62 +1,77 @@
|
||||
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) : 1
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 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">Movements</h1>
|
||||
<h1 className="text-2xl font-bold">{copy.list.title}</h1>
|
||||
</div>
|
||||
{movements.length === 0 && <div>No movements found</div>}
|
||||
{movements.length === 0 && <div>{copy.list.empty}</div>}
|
||||
{movements.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Type
|
||||
{copy.list.columns.type}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Item
|
||||
{copy.list.columns.item}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Serial Number
|
||||
{copy.list.columns.serialNumber}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Quantity
|
||||
{copy.list.columns.quantity}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Recipient
|
||||
{copy.list.columns.person}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Date
|
||||
{copy.list.columns.date}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((movement) => (
|
||||
<tr key={movement.id} className="border-b">
|
||||
<td className="p-4">{movement.type}</td>
|
||||
<td className="p-4">{movement?.item?.name}</td>
|
||||
<td className="p-4">
|
||||
{movement?.asset?.serialNumber || "-"}
|
||||
{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}
|
||||
</td>
|
||||
<td className="p-4">{movement.quantity}</td>
|
||||
<td className="p-4">
|
||||
{movement?.recipient?.firstName || "-"}{" "}
|
||||
{movement?.recipient?.lastName || "-"}
|
||||
{movement?.person
|
||||
? `${movement.person.firstName} ${movement.person.lastName}`
|
||||
: copy.fallback.missingValue}
|
||||
</td>
|
||||
<td className="p-4">{formatDate(movement.createdAt)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import EditPersonForm from "../../_components/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)
|
||||
|
||||
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}
|
||||
userFallbackCopy={userCopy.fallback}
|
||||
departmentCopy={personCopy.departments}
|
||||
fallbackCopy={personCopy.fallback}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 { formatPersonDepartment } from "../_components/person.copy"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "../_components/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.department}
|
||||
</span>
|
||||
<span>
|
||||
{formatPersonDepartment(
|
||||
person.department,
|
||||
copy.departments,
|
||||
copy.fallback,
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"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 { UserStatus } from "@/generated/prisma/client"
|
||||
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildUnifiedUpdateSchema,
|
||||
type UnifiedSchemaCopy,
|
||||
type UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import type { PersonWithUser } from "@/services/person.service"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
formatUserRole,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type UserFallbackCopy,
|
||||
type UserFormCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
|
||||
export default function EditPersonForm({
|
||||
person,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
roleLabels,
|
||||
userFallbackCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
person: PersonWithUser
|
||||
formCopy: UserFormCopy
|
||||
schemaCopy: UnifiedSchemaCopy
|
||||
roleLabels: UserRoleCopy
|
||||
userFallbackCopy: UserFallbackCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
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,
|
||||
department: person.department ?? "OTHER",
|
||||
email: person.email ?? "",
|
||||
phone: person.phone ?? "",
|
||||
...(hasUser && user
|
||||
? { role: user.role, isActive: user.status === UserStatus.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")}
|
||||
/>
|
||||
<DepartmentSelect
|
||||
error={errors.department?.message}
|
||||
formCopy={formCopy}
|
||||
departmentCopy={departmentCopy}
|
||||
fallbackCopy={fallbackCopy}
|
||||
register={register("department")}
|
||||
/>
|
||||
<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 DepartmentSelect({
|
||||
error,
|
||||
formCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
register,
|
||||
}: {
|
||||
error?: string
|
||||
formCopy: UserFormCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
register: UseFormRegisterReturn
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
>
|
||||
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||
<option key={department} value={department}>
|
||||
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||
</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 }
|
||||
@@ -0,0 +1,246 @@
|
||||
"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 { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
type UnifiedCreateFormType,
|
||||
type UnifiedSchemaCopy,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type UserFormCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
|
||||
export default function NewUserForm({
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
roleLabels,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
formCopy: UserFormCopy
|
||||
schemaCopy: UnifiedSchemaCopy
|
||||
roleLabels: UserRoleCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
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: "STAFF",
|
||||
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)}>
|
||||
<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")}
|
||||
/>
|
||||
<DepartmentSelect
|
||||
error={errors.department?.message}
|
||||
formCopy={formCopy}
|
||||
departmentCopy={departmentCopy}
|
||||
fallbackCopy={fallbackCopy}
|
||||
register={register("department")}
|
||||
/>
|
||||
<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")}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.createSubmit}
|
||||
</SubmitButton>
|
||||
</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 DepartmentSelect({
|
||||
error,
|
||||
formCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
register,
|
||||
}: {
|
||||
error?: string
|
||||
formCopy: UserFormCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
register: UseFormRegisterReturn
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
>
|
||||
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||
<option key={department} value={department}>
|
||||
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 PersonDepartmentCopy =
|
||||
Dictionary["inventory"]["people"]["departments"]
|
||||
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
|
||||
|
||||
export function formatPersonDepartment(
|
||||
department: string | null | undefined,
|
||||
departmentCopy: PersonDepartmentCopy,
|
||||
fallbackCopy: PersonFallbackCopy,
|
||||
) {
|
||||
if (!department) {
|
||||
return fallbackCopy.unknownDepartment
|
||||
}
|
||||
|
||||
return department in departmentCopy
|
||||
? departmentCopy[department as keyof PersonDepartmentCopy]
|
||||
: fallbackCopy.unknownDepartment
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
|
||||
|
||||
import TeamCreateForm from "./team.create.form"
|
||||
import TeamListTable from "./team.list.table"
|
||||
|
||||
export default async function TeamsTab() {
|
||||
const teams = await listTeamsUseCase()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
addLabel={copy.list.addLabel}
|
||||
data={teams}
|
||||
/>
|
||||
<TeamCreateForm
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
<TeamListTable
|
||||
teams={teams}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
listCopy={copy.list}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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 PersonDepartmentCopy =
|
||||
Dictionary["inventory"]["people"]["departments"]
|
||||
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
|
||||
}
|
||||
|
||||
export function formatPersonDepartment(
|
||||
department: string | null | undefined,
|
||||
departmentCopy: PersonDepartmentCopy,
|
||||
fallbackCopy: PersonFallbackCopy,
|
||||
): string {
|
||||
if (!department) {
|
||||
return fallbackCopy.unknownDepartment
|
||||
}
|
||||
|
||||
return department in departmentCopy
|
||||
? departmentCopy[department as keyof PersonDepartmentCopy]
|
||||
: fallbackCopy.unknownDepartment
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { requireRole } from "@/services/auth.service"
|
||||
|
||||
export default async function PeopleLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
|
||||
import NewPersonForm from "../_components/new.person.form"
|
||||
|
||||
export default async function NewUserPage() {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.admin.users
|
||||
|
||||
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}
|
||||
departmentCopy={dictionary.inventory.people.departments}
|
||||
fallbackCopy={dictionary.inventory.people.fallback}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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 {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
} from "./_components/person.copy"
|
||||
import TeamsTab from "./_components/teams.tab"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./_components/user.copy"
|
||||
|
||||
const VALID_TABS = ["people", "teams"] as const
|
||||
|
||||
type Tab = (typeof VALID_TABS)[number]
|
||||
|
||||
function resolveTab(raw: string | undefined): Tab {
|
||||
if (raw && VALID_TABS.includes(raw as Tab)) {
|
||||
return raw as Tab
|
||||
}
|
||||
|
||||
return "people"
|
||||
}
|
||||
|
||||
export default async function PeoplePage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
tab?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const activeTab = resolveTab(searchParams?.tab)
|
||||
const { data: people, totalPages } = await PersonService.findAllPaginated({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const teamCopy = dictionary.inventory.teams
|
||||
const userCopy = dictionary.admin.users
|
||||
const userStatusCopy = userCopy.status
|
||||
const userRoleLabels = userCopy.roles as UserRoleCopy
|
||||
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
|
||||
const departmentCopy = copy.departments as PersonDepartmentCopy
|
||||
const personFallbackCopy = copy.fallback as PersonFallbackCopy
|
||||
|
||||
const peopleList = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
link="/people/new"
|
||||
addLabel={copy.list.addLabel}
|
||||
data={people}
|
||||
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.department}
|
||||
</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">
|
||||
{formatPersonDepartment(
|
||||
person.department,
|
||||
departmentCopy,
|
||||
personFallbackCopy,
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<nav aria-label="People sections">
|
||||
<ul className="flex gap-4 border-b">
|
||||
<li>
|
||||
<Link
|
||||
href="/people?tab=people"
|
||||
aria-current={activeTab === "people" ? "page" : undefined}
|
||||
className={`inline-block px-4 py-2 ${activeTab === "people" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
{copy.list.title}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/people?tab=teams"
|
||||
aria-current={activeTab === "teams" ? "page" : undefined}
|
||||
className={`inline-block px-4 py-2 ${activeTab === "teams" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
{teamCopy.list.title}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{activeTab === "teams" && <TeamsTab />}
|
||||
{activeTab === "people" && peopleList}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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,4 +1,4 @@
|
||||
import { exec } from "child_process"
|
||||
import { exec } from "node:child_process"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { verifyUserRole } from "@/services/auth.service"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -2,6 +2,7 @@ import "@/styles/globals.css"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -18,13 +19,15 @@ export const metadata: Metadata = {
|
||||
description: "Manage your inventory with ease",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const { locale } = await getI18n()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={locale}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user