109 Commits

Author SHA1 Message Date
aferrer 7abf298da1 refactor(items): localize stock policy form resolver typing 2026-07-01 01:28:49 +02:00
aferrer 79bd1b5d5e feat(items): add stock policy fields 2026-07-01 01:06:10 +02:00
aferrer d1def3353a chore(navigation): reorder assignments and movements 2026-06-30 10:06:49 +02:00
aferrer 66c703e484 test(users): align unified form no-user default 2026-06-30 10:06:42 +02:00
aferrer 925bafff1a fix(common): make page header search opt-in 2026-06-30 10:06:35 +02:00
aferrer 1e9c5b9627 fix(assignments): normalize asset-backed quantity 2026-06-30 10:06:27 +02:00
aferrer 723c41f0c9 feat(people): split people and teams into tabs 2026-06-30 10:06:20 +02:00
aferrer 15494b2539 chore: Configure editor to format on save and set default formatter for TypeScript variants 2026-06-29 13:55:20 +02:00
aferrer efda051aa3 feat(teams): add Team entity and replace PersonDepartment with teamId (#6)
Co-authored-by: Asis Ferrer <aferrer@aferrer.dev>
Co-committed-by: Asis Ferrer <aferrer@aferrer.dev>
2026-06-26 00:32:37 +00:00
aferrer b401f254ec feat(assignments): remaining quantity display and partial return i18n 2026-06-25 21:42:09 +02:00
aferrer 2c03cd4d66 feat(assignments): partial return action and ReturnButton modal 2026-06-25 21:18:34 +02:00
aferrer de40e0bf73 chore(deps): add jsdom and testing-library for modal tests 2026-06-25 21:07:06 +02:00
aferrer e4bd76a353 feat(assignments): partial return schema, concurrency guard, domain error 2026-06-25 21:02:46 +02:00
stock-manager-bot 9b023ee558 fix(schemas): fix asset schema Zod generic and form test types 2026-06-25 19:54:46 +02:00
stock-manager-bot 44565e3a62 test(cleanup): fix pre-existing test mocks and data 2026-06-25 19:50:02 +02:00
aferrer 18e274ef37 feat(assignments): close and reopen assignment on person swap 2026-06-25 17:20:24 +02:00
aferrer b4b63e107a feat(assets): pair ASSIGNMENT movement on itemChanged AVAILABLE→ASSIGNED 2026-06-25 17:05:23 +02:00
aferrer 91dc0220ae feat(assignments): emit ADJUSTMENT movement on quantity change 2026-06-25 16:58:03 +02:00
aferrer 1142855578 feat(assets): thread previousStatus through movement writes 2026-06-25 03:25:47 +02:00
aferrer a0a1e1bdc8 feat(movements): gate StockMovementLine on trackingType QUANTITY 2026-06-25 03:22:08 +02:00
aferrer 8f7a406e83 fix(assets): omit phantom stock line for ASSIGNED assets 2026-06-25 03:14:00 +02:00
aferrer 95c52579d1 refactor(movements): normalize snapshot convention to mutate-then-write 2026-06-25 03:11:15 +02:00
aferrer 575cd2d9a0 feat(items): emit ISSUE/ADJUSTMENT movement on stock decrease 2026-06-25 03:03:26 +02:00
aferrer 0d38626f3a test(cleanup): remove legacy admin users references 2026-06-20 16:27:36 +02:00
aferrer d217edd4e5 test(i18n): sync inventory dictionary contracts 2026-06-19 17:14:22 +02:00
aferrer f32d55a7b0 feat(assets): add asset metadata views and enforce assignment transitions 2026-06-19 17:14:22 +02:00
aferrer c1763ed007 feat(assignments): support line-based returns and authenticated updates 2026-06-19 17:14:22 +02:00
aferrer 965a04a468 feat(items): adapt item flows to inventory schema defaults and SKU generation 2026-06-19 17:14:22 +02:00
aferrer 7b8a415c6a feat(people): align people, users, and categories with active inventory records 2026-06-19 17:14:22 +02:00
aferrer c123170a46 test(e2e): forbid focused Playwright tests in CI 2026-06-19 01:05:33 +02:00
aferrer 6d34a2f74f feat(inventory): support line-based assignments and movements 2026-06-19 01:05:33 +02:00
aferrer 8e6a00c2a9 feat(people): adapt person user flows to status model 2026-06-19 01:05:33 +02:00
aferrer 01d89cd21b feat(auth): align login and bootstrap with new user schema 2026-06-19 01:05:33 +02:00
aferrer 2ed9445f7f feat(db): reshape user and inventory schema 2026-06-19 01:05:33 +02:00
aferrer 1a95bf4613 docs: update README to reflect Person model and /people migration 2026-06-18 23:27:12 +02:00
aferrer fff5ce01e5 chore: add openspec and sdd to gitignore 2026-06-18 22:51:08 +02:00
aferrer fb5f3da7c1 feat: add quick-assign action from people list with person preselected 2026-06-18 22:46:35 +02:00
aferrer df02d24d79 refactor: remove obsolete admin/users files after /people migration 2026-06-18 22:45:39 +02:00
aferrer 92f83c708a refactor(i18n): update Spanish (es) locale strings for delivery note 2026-06-17 09:41:32 +02:00
aferrer d6b42d78e7 refactor: consolidate admin/users management under /people 2026-06-17 09:32:26 +02:00
aferrer 4f370eee70 refactor: move unified Person+User form to /people/new, admin-only 2026-06-17 08:51:23 +02:00
aferrer 1f5a849bf5 feat: unify Person and User creation form with conditional password 2026-06-16 21:48:59 +02:00
aferrer e5717461cf feat: add unified Person+User creation backend 2026-06-16 21:21:17 +02:00
aferrer 68c2983d36 refactor: remove username from User model, login by email only 2026-06-16 16:18:42 +02:00
aferrer caf19575c6 refactor: rename DB columns recipientId to personId 2026-06-16 13:37:58 +02:00
aferrer cf6820a7aa refactor: rename remaining recipient references to person/people 2026-06-16 13:34:15 +02:00
aferrer 29c7c19cd8 refactor: complete i18n rename recipients to people, finalize tests 2026-06-16 12:25:57 +02:00
aferrer ecc3cf1b55 refactor: rename recipients route to people, update all frontend references 2026-06-16 11:26:21 +02:00
aferrer d67f31cf54 refactor: rename Recipient to Person, remove username, add userId FK 2026-06-16 10:04:24 +02:00
aferrer befe1f3f82 fix(validation): replace deprecated Zod error flatten calls 2026-06-15 21:59:02 +02:00
aferrer 20552ba68b docs: document i18n architecture and localized surfaces 2026-06-15 17:04:36 +02:00
aferrer 73552dbb05 feat(i18n): localize admin users UI surfaces 2026-06-15 16:01:19 +02:00
aferrer 0cbbe60299 feat(i18n): localize admin users backbone 2026-06-15 15:35:08 +02:00
aferrer 349559f4e0 feat(i18n): localize assignment validation messages 2026-06-15 01:15:24 +02:00
aferrer bfea2b77ab feat(i18n): localize inventory assignments UI 2026-06-15 00:47:25 +02:00
aferrer 9b713c42e2 feat(i18n): localize recipient validation messages 2026-06-14 22:41:20 +02:00
aferrer c0ae7a034a feat(i18n): localize recipients UI 2026-06-14 18:33:57 +02:00
aferrer ea37fc8d70 test(i18n): cover movement page localization 2026-06-14 02:05:01 +02:00
aferrer f62cd6fb37 feat(i18n): localize movement UI 2026-06-14 01:20:23 +02:00
aferrer 7d5ab64653 feat(i18n): localize asset validation messages 2026-06-13 17:23:01 +02:00
aferrer 3d6b13dc1c feat(i18n): localize inventory assets UI 2026-06-13 17:07:51 +02:00
aferrer c67e86c91b feat(i18n): localize item validation messages 2026-06-13 11:28:28 +02:00
aferrer 964b1648ca feat(i18n): localize inventory items UI 2026-06-13 11:12:02 +02:00
aferrer 9f7d1b8ef8 feat(i18n): localize category action messages 2026-06-12 23:21:08 +02:00
aferrer e9a07eb28e feat(i18n): localize inventory categories UI 2026-06-12 23:01:33 +02:00
aferrer 589b042e7c feat(i18n): localize submit button states 2026-06-12 09:51:19 +02:00
aferrer 2fa6611719 feat(i18n): localize shell and common UI 2026-06-11 17:54:56 +02:00
aferrer c3cf4182ad feat(i18n): add language switcher 2026-06-11 16:16:06 +02:00
aferrer 18a192a069 chore: apply Biome cleanup 2026-06-11 04:56:01 +02:00
aferrer ac3dfe69cd feat(i18n): add locale dictionaries and pilot surfaces 2026-06-11 04:55:47 +02:00
aferrer 2c6d6bffcd docs: document testing setup 2026-06-07 16:56:33 +02:00
aferrer f2b9239d82 test: add initial unit integration and e2e coverage
Adds the initial testing baseline for the project:

   Unit coverage:
   - Zod schemas for items, assignments, movements, categories, auth, recipients, users, and assets
   - password hashing and verification helpers
   - auth role helper functions

   Integration coverage with PostgreSQL Testcontainers:
   - item use-cases: create, duplicate names, delete constraints
   - assignment use-cases: create, insufficient stock, return, double return
   - asset use-cases: available/assigned creation and lifecycle transitions
   - user use-cases: create/update, uniqueness, admin safeguards, password reset
   - category use-cases: create/update/delete constraints
   - recipient use-cases: create/update and uniqueness constraints

   E2E smoke coverage with Playwright:
   - unauthenticated redirect to login
   - seeded admin login
   - dashboard load
   - admin users page
   - inventory items page
   - assignments page

   Also configures:
   - Vitest
   - Playwright
   - PostgreSQL Testcontainers helpers
   - deterministic E2E admin bootstrap
   - test artifact ignores

   Validation:
   - bun run test: 9 files / 37 tests passed
   - bun run test:e2e: 3 passed
   - bunx tsc --noEmit: passed
   - bunx prisma validate: passed
2026-06-07 04:14:01 +02:00
aferrer cb01f4f8ef docs: update README for use-case architecture 2026-06-05 15:30:21 +02:00
aferrer bc5926a5de chore(prisma): fold item name unique index into init migration 2026-06-04 22:42:51 +02:00
aferrer 16a68e01ab Merge pull request #3 from refactor/use-cases-architecture
refactor: split actions into use cases and repositories
2026-06-04 20:31:43 +00:00
aferrer a7f7ace527 refactor(structure): move legacy import and remove lib leftovers 2026-06-04 22:13:26 +02:00
aferrer da9ae0582b fix(auth): update login imports after action move 2026-06-04 22:13:00 +02:00
aferrer 24d2d59bbc refactor(recipients): move mutations into use cases 2026-06-04 22:12:36 +02:00
aferrer f48ccb8c50 refactor(categories): move mutations into use cases 2026-06-04 22:12:06 +02:00
aferrer 0af25417ab refactor(items): move workflows into use cases 2026-06-04 22:11:40 +02:00
aferrer 2b908b24f6 refactor(assets): move workflows into use cases 2026-06-04 22:10:43 +02:00
aferrer e88fb2e6d4 refactor(assignments): move workflows into use cases 2026-06-04 22:09:54 +02:00
aferrer 5034ec0646 feat(users): add admin user management and bootstrap seed 2026-06-04 22:03:13 +02:00
aferrer 12cbec92a0 feat(auth): add role guards and protect admin routes 2026-06-04 21:57:39 +02:00
aferrer 601dea9526 chore(tooling): align biome vscode and ui config 2026-06-04 21:53:42 +02:00
aferrer 9ecb543c18 chore(prisma): move schema and generated client workflow 2026-06-04 21:53:08 +02:00
aferrer 72973bfb3f refactor: use z.input for item schema form types 2026-05-14 12:58:26 +02:00
aferrer 4b40f50e7f refactor: add and use assignment data output type in services 2026-05-14 11:05:19 +02:00
aferrer feae1d2cda chore: update gitignore for prisma generated files and VSCode settings 2026-05-12 00:55:25 +02:00
aferrer 0d877cbba6 chore: update biome.json ignore configuration 2026-05-12 00:54:32 +02:00
aferrer eb07760748 fix: redirect to login page after sign out 2026-05-12 00:53:35 +02:00
aferrer d748e3e6c5 refactor: remove unused SITE_URL constant 2026-05-12 00:52:54 +02:00
aferrer 9c7e987d6e fix: add button type attribute to reset button 2026-05-12 00:51:24 +02:00
aferrer e75cd424e3 refactor: fix React keys and cleanup effect dependencies in sidebar 2026-05-12 00:50:46 +02:00
aferrer 1ec992caf6 refactor: add proper types, fix zod error handling, and simplify import mapping logic 2026-05-12 00:49:23 +02:00
aferrer bb0948f590 refactor: use z.input instead of z.infer for form types in assignment schemas 2026-05-12 00:47:52 +02:00
aferrer 6f16d26a8e refactor: simplify optional checks and boolean coercion in services 2026-05-12 00:46:41 +02:00
aferrer a7b547a92d types: replace any with unknown 2026-05-12 00:44:46 +02:00
aferrer d60801e6c2 style: use explicit node builtin import for child_process 2026-05-12 00:43:27 +02:00
aferrer 51e7a98d3f style: replace string concatenation with template literals 2026-05-12 00:42:21 +02:00
aferrer fab2ba8835 chore: prevent unhandled promise lint warnings in assignment toast handlers 2026-05-12 00:40:44 +02:00
aferrer 5bb5223cd9 fix: Use radix 10 for parsing page parameter in search components 2026-05-12 00:37:42 +02:00
aferrer c25a8e0da3 feat: add accessibility roles and aria-labels to SVG icons in the home page 2026-05-12 00:35:03 +02:00
aferrer 5ac2dc5277 refactor: update type imports to use 'type' for better TypeScript compatibility across the codebase 2026-05-11 19:06:54 +02:00
aferrer fd18692110 refactor: update Zod schemas to v4 2026-05-11 18:59:30 +02:00
aferrer f5c759fc3a feat: add custom dark variant to globals.css 2026-05-11 18:48:02 +02:00
aferrer b2fc8b83ad chore: update biome settings 2026-05-11 18:46:56 +02:00
aferrer ba7e650c70 chore: update ui components 2026-05-11 18:20:23 +02:00
aferrer 1bf6729d52 chore: update dependencies and migrate tooling 2026-05-11 18:11:06 +02:00
305 changed files with 26924 additions and 25521 deletions
+4 -8
View File
@@ -10,10 +10,7 @@
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // 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. // This can be used to network with other containers or with the host.
"forwardPorts": [ "forwardPorts": [3000, 5432],
3000,
5432
],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bun i", "postCreateCommand": "bun i",
// Use 'postStartCommand' to run commands after the container is started. // Use 'postStartCommand' to run commands after the container is started.
@@ -23,14 +20,13 @@
"vscode": { "vscode": {
"extensions": [ "extensions": [
"oven.bun-vscode", "oven.bun-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"YoavBls.pretty-ts-errors", "YoavBls.pretty-ts-errors",
"usernamehw.errorlens", "usernamehw.errorlens",
"Prisma.prisma", "Prisma.prisma",
"esbenp.prettier-vscode",
"dsznajder.es7-react-js-snippets", "dsznajder.es7-react-js-snippets",
"csstools.postcss" "csstools.postcss",
"biomejs.biome"
] ]
} }
}, },
@@ -39,4 +35,4 @@
"containerEnv": { "containerEnv": {
"SHELL": "/bin/bash" "SHELL": "/bin/bash"
} }
} }
+8 -1
View File
@@ -13,4 +13,11 @@ NODE_ENV=production
DEMO_MODE=false DEMO_MODE=false
DOMAIN=localhost DOMAIN=localhost
AUTH_TRUST_HOST="http://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
View File
@@ -12,11 +12,16 @@
# testing # testing
/coverage /coverage
/test-results
/playwright-report
# next.js # next.js
/.next/ /.next/
/out/ /out/
# prisma
src/generated
# production # production
/build /build
@@ -39,3 +44,12 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# vscode
!.vscode
# Local Pi runtime state
.atl/
.pi/
openspec/
sdd/
-15
View File
@@ -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
View File
@@ -1,11 +0,0 @@
{
"useTabs": false,
"trailingComma": "all",
"semi": false,
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"endOfLine": "auto",
"arrowParens": "always",
"plugins": ["prettier-plugin-tailwindcss"]
}
+18 -19
View File
@@ -1,20 +1,19 @@
{ {
"editor.codeActionsOnSave": { "editor.formatOnSave": true,
"source.fixAll.eslint": "always" "editor.codeActionsOnSave": {
}, "source.organizeImports.biome": "explicit",
"eslint.useFlatConfig": true, "source.fixAll.biome": "explicit"
"eslint.format.enable": true, },
"eslint.run": "onSave", "[json]": {
"[typescript]": { "editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "esbenp.prettier-vscode" },
}, "[jsonc]": {
"[json]": { "editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features" },
}, "[typescript]": {
"[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode" },
}, "[typescriptreact]": {
"[jsonc]": { "editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features" }
} }
}
+11
View File
@@ -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
View File
@@ -39,4 +39,4 @@ COPY --from=builder /app/.next/static ./.next/static
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["bun", "run", "start"] CMD ["sh", "-c", "bun run db:deploy && bun run db:seed && bun run start"]
+354 -186
View File
@@ -1,238 +1,406 @@
# Stock Manager Home # 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 ## Quick start
Stock Manager es una aplicación web completa para la gestión de inventarios, activos y asignaciones de equipamiento. Permite controlar tanto ítems genéricos (gestionados por cantidad) como activos serializados (con número de serie único), registrar movimientos de stock, y gestionar asignaciones a destinatarios por departamentos.
## ✨ Características principales
### Gestión de Inventario
- **Ítems genéricos**: Productos sin número de serie, gestionados por cantidad en stock
- **Activos serializados**: Equipos individuales con número de serie único
- **Categorías**: Organización jerárquica de productos
- **Control de stock**: Niveles mínimos/máximos, alertas de stock bajo (WIP)
- **Estados múltiples**: Disponible, Asignado, Reservado, En reparación, Averiado, Robado, Dado de baja
### Gestión de Destinatarios
- Registro de empleados/destinatarios por departamento
- Información de contacto (email, teléfono)
- Historial de asignaciones por destinatario
### Sistema de Asignaciones
- Asignación de ítems genéricos (por cantidad)
- Asignación de activos individuales (uno a uno)
- Seguimiento de fechas de asignación y devolución
- Notas y detalles de cada asignación
- Registro del usuario que realiza cada asignación
### Movimientos e Historial
- Registro completo de todos los movimientos de stock
- Tipos de movimiento: IN, OUT, ASSIGNMENT, RETURN, ADJUSTMENT, DELETED
- Trazabilidad completa con stock previo y nuevo
- Auditoría de todos los cambios con usuario y fecha
### Importación de Datos
- Importación masiva vía CSV
- Plantilla descargable para importaciones
- Validación de datos en el proceso de importación
### Sistema de Autenticación y Roles
- Autenticación segura con NextAuth v5
- 4 roles de usuario: ADMIN, MANAGER, STAFF, VIEWER
- Permisos diferenciados según rol
- Contraseñas hasheadas con bcrypt
## 🚀 Tecnologías
- **Framework**: Next.js 15 (App Router)
- **Base de datos**: PostgreSQL 18
- **ORM**: Prisma 6
- **Autenticación**: NextAuth v5
- **UI**: React 19, Tailwind CSS, Shadcn
- **Validación**: Zod
- **Formularios**: React Hook Form
- **Runtime**: Bun (recomendado)
- **Containerización**: Docker + Docker Compose
## 🔨 Desarrollo en entorno DevContainer
Este proyecto incluye configuración para desarrollo en contenedor usando [DevContainer](https://containers.dev/).
1. Abre el proyecto en VS Code y selecciona "Reopen in Container".
2. El entorno instalará dependencias automáticamente (bun i) y lanzará el servidor de desarrollo (bun run dev).
3. El puerto 3000 estará disponible para acceder a la app desde tu navegador.
## 🔨 Desarrollo local
### Prerrequisitos
- Node.js 18+ o Bun
- PostgreSQL 13+ (o usar Docker Compose)
- Git
1. Clonar el repositorio:
```bash
git clone <repo-url>
cd stock-manager
```
2. Instalar dependencias:
```bash ```bash
bun install bun install
# o cp .env.example .env
npm install 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 ```bash
cp .env.example .env cp .env.example .env
``` ```
Editar `.env` con tus configuraciones: Variables principales:
```env | Grupo | Variables |
# Database |-------|-----------|
DATABASE_URL="postgresql://user:password@localhost:5432/stockmanager" | Base de datos | `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `POSTGRES_HOST`, `POSTGRES_PORT` |
POSTGRES_USER=user | Auth | `AUTH_SECRET`, `AUTH_TRUST_HOST`, `DOMAIN`, `NODE_ENV`, `DEMO_MODE` |
POSTGRES_PASSWORD=password | Bootstrap admin | `ADMIN_BOOTSTRAP_ENABLED`, `ADMIN_EMAIL`, `ADMIN_NAME`, `ADMIN_PASSWORD` |
POSTGRES_DB=stockmanager
POSTGRES_HOST=db
POSTGRES_PORT=5432
# NextAuth ### Bootstrap admin
NODE_ENV=development
DEMO_MODE=false
AUTH_SECRET="your-secret-key-here"
AUTH_TRUST_HOST=true
DOMAIN=localhost:3000
```
4. Ejecutar migraciones de base de datos: El seed ejecuta `prisma/seed.ts`, que llama a `prisma/bootstrap-admin.ts`.
```bash Comportamiento:
bun run db:migrate
# o generar el cliente Prisma
bun run db:generate
```
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 ```bash
bun run db:seed 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 ```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 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 ```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 ```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 ```bash
# Desarrollo bun run db:generate
bun run dev # Inicia servidor de desarrollo con Turbopack
bun run build # Construye para producción
bun run start # Inicia servidor de producción
# Linting y formato
bun run lint # Ejecuta ESLint
bun run lint:fix # Corrige errores de ESLint automáticamente
bun run format # Formatea código con Prettier
# Base de datos
bun run db:push # Sincroniza schema sin migraciones
bun run db:migrate # Crea y ejecuta migraciones
bun run db:migrate:reset # Resetea BD y ejecuta migraciones
bun run db:deploy # Ejecuta migraciones en producción
bun run db:generate # Genera cliente Prisma
bun run db:studio # Abre Prisma Studio (GUI para BD)
``` ```
## 📁 Estructura del proyecto 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/ src/
├── app/ # App Router de Next.js ├── actions/ # Server Actions finas
│ ├── (auth)/ # Rutas de autenticación ├── app/ # Next.js App Router
│ └── login/ ├── (auth)/ # Login
│ ├── (dashboard)/ # Rutas del dashboard │ ├── (dashboard)/ # Dashboard, inventario, asignaciones, importación, people
│ ├── (home)/ # Página principal │ ├── api/ # API routes
│ ├── assignments/ # Gestión de asignaciones └── forbidden/ # Página de acceso denegado
│ │ ├── import/ # Importación de datos ├── components/ # Componentes compartidos y UI
│ │ ├── inventory/ # Gestión de inventario ├── generated/ # Cliente Prisma generado, ignorado por Git
│ │ │ ├── assets/ # Activos serializados ├── hooks/ # Hooks React
├── categories/# Categorías ├── lib/ # Infraestructura y utilidades
└── items/ # Ítems genéricos ├── schemas/ # Schemas Zod
│ │ ├── movements/ # Historial de movimientos ├── services/ # Repositories Prisma / read models
└── recipients/ # Gestión de destinatarios ├── styles/ # Estilos globales
│ └── api/ # API routes ├── types/ # Tipos compartidos
│ ├── auth/ # Endpoints de autenticación └── use-cases/ # Casos de uso transaccionales
│ └── db/ # Endpoints de base de datos
├── components/ # Componentes React prisma/
│ ├── auth/ # Componentes de autenticación ├── bootstrap-admin.ts # Crea/activa admin inicial si corresponde
│ ├── common/ # Componentes comunes ├── migrations/ # Migraciones Prisma
│ ├── forms/ # Componentes de formularios ├── schema.prisma # Modelo de datos
│ ├── layout/ # Componentes de layout └── seed.ts # Entry point de seed
│ └── ui/ # Componentes UI (Radix)
├── lib/ # Utilidades y configuración
│ ├── actions/ # Server Actions
│ ├── schemas/ # Schemas de validación Zod
│ └── types/ # Tipos TypeScript
├── services/ # Servicios de lógica de negocio
├── prisma/ # Schema y migraciones Prisma
│ ├── schema.prisma # Definición del modelo de datos
│ ├── migrations/ # Historial de migraciones
│ └── seed.ts # Datos iniciales
└── styles/ # Estilos globales
``` ```
## 🔐 Seguridad ## Autenticación y autorización
- Autenticación mediante JWT - Login con NextAuth credentials.
- Contraseñas hasheadas con bcrypt - Passwords hasheadas con `bcryptjs`.
- Validación de datos con Zod en cliente y servidor - Roles soportados: `ADMIN`, `MANAGER`, `STAFF`, `VIEWER`.
- Protección de rutas según roles - `/people/*` requiere rol `ADMIN` para operaciones de gestión.
- Variables de entorno para secretos - Usuarios inactivos no pueden iniciar sesión.
- Sanitización de inputs
## 🗃️ 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 ## Modelo de datos
- **Recipients**: Destinatarios/empleados que reciben asignaciones
- **Categories**: Categorías de productos
- **Items**: Ítems genéricos (sin número de serie)
- **Assets**: Activos individuales (con número de serie)
- **Assignments**: Asignaciones de ítems/activos a destinatarios
- **Movements**: Registro de todos los movimientos de inventario
Ver `src/prisma/schema.prisma` para el esquema completo. 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
View File
@@ -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"
}
}
}
}
+860 -536
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/styles/globals.css",
"baseColor": "zinc", "baseColor": "zinc",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
+4
View File
@@ -37,6 +37,10 @@ services:
DOMAIN: ${DOMAIN} DOMAIN: ${DOMAIN}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST} AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
AUTH_SECRET: ${AUTH_SECRET} 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 DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
depends_on: depends_on:
- db - db
-59
View File
@@ -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[]
-6
View File
@@ -2,12 +2,6 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* 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 export default nextConfig
+37 -33
View File
@@ -2,29 +2,33 @@
"name": "stock-manager", "name": "stock-manager",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "bun@1.3.14",
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"next-lint": "next lint", "lint": "biome lint --write",
"lint": "eslint .", "format": "biome format --write",
"lint:fix": "eslint . --fix", "check": "biome check --write",
"format": "prettier --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:push": "bunx prisma db push",
"db:migrate": "bunx prisma migrate dev", "db:migrate": "bunx prisma migrate dev",
"db:migrate:reset": "bunx prisma migrate reset", "db:migrate:reset": "bunx prisma migrate reset",
"db:deploy": "bunx prisma migrate deploy", "db:deploy": "bunx prisma migrate deploy",
"db:generate": "bunx prisma generate", "db:generate": "bunx prisma generate",
"db:seed": "bunx --bun prisma db seed",
"db:studio": "bunx prisma studio" "db:studio": "bunx prisma studio"
}, },
"prisma": {
"schema": "src/prisma/schema.prisma",
"seed": "bun src/prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@eslint/js": "^9.29.0", "@base-ui/react": "^1.4.1",
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.10.1", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
@@ -36,40 +40,40 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.518.0", "dotenv": "^17.4.2",
"next": "15.3.6", "lucide-react": "^1.17.0",
"next": "^16.2.4",
"next-auth": "^5.0.0-beta.28", "next-auth": "^5.0.0-beta.28",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"react": "^19.1.0", "radix-ui": "^1.4.3",
"react-dom": "^19.1.0", "react": "^19.2.5",
"react-hook-form": "^7.58.1", "react-dom": "^19.2.5",
"sonner": "^2.0.5", "react-hook-form": "^7.74.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6", "use-debounce": "^10.0.6",
"zod": "^3.25.67" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@biomejs/biome": "2.4.15",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.1.10", "@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/node": "^24.0.3",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@typescript-eslint/parser": "^8.34.1", "@vitest/coverage-v8": "^4.1.8",
"eslint": "^9.29.0", "jsdom": "^29.1.1",
"eslint-config-next": "15.3.4", "prisma": "^7.8.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^59.0.1",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.13",
"prisma": "^6.10.1",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.10",
"testcontainers": "^12.0.1",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vitest": "^4.1.8"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@prisma/client", "@prisma/client",
@@ -79,4 +83,4 @@
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ]
} }
+31
View File
@@ -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,
},
})
+13
View File
@@ -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"),
},
})
+149
View File
@@ -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,41 @@
BEGIN;
-- Seed legacy teams from the old PersonDepartment enum English display names.
INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt")
VALUES
(gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (lower("name")) DO NOTHING;
-- Backfill Person.teamId from the legacy Person.department enum values.
UPDATE "Person"
SET "teamId" = (
SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department"
WHEN 'IT' THEN 'IT'
WHEN 'ENGINEERING' THEN 'Engineering'
WHEN 'LOGISTICS' THEN 'Logistics'
WHEN 'TRAFFIC' THEN 'Traffic'
WHEN 'DRIVER' THEN 'Driver'
WHEN 'ADMINISTRATION' THEN 'Administration'
WHEN 'SALES' THEN 'Sales'
WHEN 'OTHER' THEN 'Other'
END)
)
WHERE "department" IS NOT NULL;
-- Drop the legacy department index.
DROP INDEX "Person_department_deletedAt_idx";
-- Drop the legacy department column.
ALTER TABLE "Person" DROP COLUMN "department";
-- Drop the legacy enum type.
DROP TYPE "PersonDepartment";
COMMIT;
+580
View File
@@ -0,0 +1,580 @@
// This is your Prisma schema file,
// learn more about the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
}
// ======================================================
// USERS
// ======================================================
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
enum UserStatus {
INVITED
ACTIVE
SUSPENDED
DISABLED
}
model User {
id String @id @default(uuid(7)) @db.Uuid
name String
email String
emailNormalized String @unique
/**
* Nulo mientras el usuario no haya aceptado la invitación.
*/
passwordHash String?
role UserRole @default(STAFF)
status UserStatus @default(INVITED)
deletedAt DateTime?
invitedAt DateTime?
activatedAt DateTime?
passwordChangedAt DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
person Person?
createdAssignments Assignment[] @relation("AssignmentCreatedBy")
closedAssignments Assignment[] @relation("AssignmentClosedBy")
receivedStockReturns AssignmentStockReturn[]
receivedAssetReturns AssignmentAssetLine[] @relation("AssetReturnedBy")
movements InventoryMovement[]
acknowledgedStockAlerts StockAlert[] @relation("StockAlertAcknowledgedBy")
sentInvitations UserInvitation[] @relation("UserInvitationInvitedBy")
invitations UserInvitation[]
@@index([status])
@@index([deletedAt])
@@index([createdAt])
}
model UserInvitation {
id String @id @default(uuid(7)) @db.Uuid
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
/**
* Hash del token de invitación.
* Nunca guardar el token plano.
*/
tokenHash String @unique
invitedById String @db.Uuid
invitedBy User @relation("UserInvitationInvitedBy", fields: [invitedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
email String
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([acceptedAt])
@@index([revokedAt])
}
// ======================================================
// PEOPLE
// ======================================================
model Person {
id String @id @default(uuid(7)) @db.Uuid
firstName String
lastName String
email String?
phone String?
teamId String? @db.Uuid
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String? @unique @db.Uuid
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignments Assignment[]
@@index([lastName, firstName])
@@index([teamId, deletedAt])
@@index([teamId])
@@index([deletedAt])
}
model Team {
id String @id @default(uuid(7)) @db.Uuid
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
people Person[]
}
// ======================================================
// CATALOG
// ======================================================
enum ItemTrackingType {
QUANTITY
SERIALIZED
}
enum ItemStatus {
ACTIVE
DISCONTINUED
ARCHIVED
}
model Category {
id String @id @default(uuid(7)) @db.Uuid
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
items Item[]
@@index([deletedAt])
}
model Item {
id String @id @default(uuid(7)) @db.Uuid
sku String @unique
name String
description String?
trackingType ItemTrackingType
status ItemStatus @default(ACTIVE)
categoryId String @db.Uuid
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: Cascade)
/**
* Solo se utiliza para artículos QUANTITY.
* Para artículos SERIALIZED, las existencias se obtienen
* contando los activos AVAILABLE.
*/
stock Int @default(0)
/**
* Umbral de alerta.
* QUANTITY:
* Se compara contra Item.stock.
* SERIALIZED:
* Se compara contra número de Asset AVAILABLE.
*/
minStock Int?
/**
* Nivel deseado tras reposición.
* Compra sugerida:
* targetStock - stock disponible.
*/
targetStock Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assets Asset[]
assignmentStockLines AssignmentStockLine[]
stockMovementLines StockMovementLine[]
stockAlerts StockAlert[]
@@index([categoryId, status])
@@index([trackingType, status])
@@index([name])
@@index([deletedAt])
}
// ======================================================
// SERIALIZED ASSETS
// ======================================================
enum AssetStatus {
AVAILABLE
ASSIGNED
IN_REPAIR
BROKEN
LOST
STOLEN
DISPOSED
RETIRED
}
model Asset {
id String @id @default(uuid(7)) @db.Uuid
/**
* Identificador interno visible.
* Ejemplos:
* IT-000001
* LAP-000042
* MON-000117
*/
assetTag String? @unique
/**
* Número de serie del fabricante.
* Puede ser nulo.
*/
serialNumber String @unique
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
status AssetStatus @default(AVAILABLE)
manufacturer String?
model String?
deliveryNote String?
invoiceNumber String?
purchaseDate DateTime?
purchasePrice Decimal? @db.Decimal(12, 2)
warrantyEndsAt DateTime?
notes String?
retiredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignmentLines AssignmentAssetLine[]
movementLines AssetMovementLine[]
@@index([itemId, status])
@@index([status])
@@index([createdAt])
@@index([deletedAt])
}
// ======================================================
// ASSIGNMENTS
// ======================================================
enum AssignmentStatus {
OPEN
PARTIALLY_RETURNED
RETURNED
CANCELLED
}
model Assignment {
id String @id @default(uuid(7)) @db.Uuid
personId String @db.Uuid
person Person @relation(fields: [personId], references: [id], onDelete: Restrict, onUpdate: Cascade)
status AssignmentStatus @default(OPEN)
assignedAt DateTime @default(now())
dueAt DateTime?
closedAt DateTime?
notes String?
createdById String @db.Uuid
createdBy User @relation("AssignmentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict, onUpdate: Cascade)
closedById String? @db.Uuid
closedBy User? @relation("AssignmentClosedBy", fields: [closedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stockLines AssignmentStockLine[]
assetLines AssignmentAssetLine[]
movements InventoryMovement[]
@@index([personId, status])
@@index([personId, assignedAt])
@@index([status, assignedAt])
@@index([dueAt])
@@index([createdById, createdAt])
}
// ======================================================
// QUANTITY ASSIGNMENTS
// ======================================================
model AssignmentStockLine {
id String @id @default(uuid(7)) @db.Uuid
assignmentId String @db.Uuid
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
quantity Int
returnedQuantity Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
returns AssignmentStockReturn[]
@@index([assignmentId])
@@index([itemId, createdAt])
}
model AssignmentStockReturn {
id String @id @default(uuid(7)) @db.Uuid
assignmentLineId String @db.Uuid
assignmentLine AssignmentStockLine @relation(fields: [assignmentLineId], references: [id], onDelete: Restrict, onUpdate: Cascade)
quantity Int
returnedAt DateTime @default(now())
receivedById String @db.Uuid
receivedBy User @relation(fields: [receivedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
notes String?
createdAt DateTime @default(now())
@@index([assignmentLineId, returnedAt])
@@index([receivedById, returnedAt])
}
// ======================================================
// SERIALIZED ASSET ASSIGNMENTS
// ======================================================
model AssignmentAssetLine {
id String @id @default(uuid(7)) @db.Uuid
assignmentId String @db.Uuid
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Restrict, onUpdate: Cascade)
assignedAt DateTime @default(now())
returnedAt DateTime?
returnedById String? @db.Uuid
returnedBy User? @relation("AssetReturnedBy", fields: [returnedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
returnStatus AssetStatus?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/**
* La unicidad de asignación activa se protege
* mediante índice único parcial en PostgreSQL.
*/
@@index([assignmentId])
@@index([assetId, assignedAt])
@@index([returnedAt])
}
// ======================================================
// INVENTORY MOVEMENTS
// ======================================================
enum InventoryMovementType {
RECEIPT
ISSUE
ASSIGNMENT
RETURN
ADJUSTMENT
STATUS_CHANGE
DISPOSAL
INITIAL_LOAD
}
enum InventoryMovementReason {
PURCHASE
MANUAL_ENTRY
EMPLOYEE_ASSIGNMENT
EMPLOYEE_RETURN
INVENTORY_CORRECTION
DAMAGE
REPAIR
REPAIR_RETURN
LOSS
THEFT
DISPOSAL
INITIAL_LOAD
OTHER
}
model InventoryMovement {
id String @id @default(uuid(7)) @db.Uuid
type InventoryMovementType
reason InventoryMovementReason
assignmentId String? @db.Uuid
assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Restrict, onUpdate: Cascade)
reference String?
details String?
notes String?
performedById String @db.Uuid
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict, onUpdate: Cascade)
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
stockLines StockMovementLine[]
assetLines AssetMovementLine[]
@@index([type, occurredAt])
@@index([reason, occurredAt])
@@index([assignmentId])
@@index([performedById, occurredAt])
@@index([occurredAt])
}
// ======================================================
// QUANTITY MOVEMENTS
// ======================================================
model StockMovementLine {
id String @id @default(uuid(7)) @db.Uuid
movementId String @db.Uuid
movement InventoryMovement @relation(fields: [movementId], references: [id], onDelete: Cascade, onUpdate: Cascade)
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
/**
* Positivo: entrada/devolución/ajuste positivo.
* Negativo: salida/asignación/ajuste negativo.
*/
stockDelta Int
previousStock Int
newStock Int
createdAt DateTime @default(now())
@@index([movementId])
@@index([itemId, createdAt])
}
// ======================================================
// SERIALIZED ASSET MOVEMENTS
// ======================================================
model AssetMovementLine {
id String @id @default(uuid(7)) @db.Uuid
movementId String @db.Uuid
movement InventoryMovement @relation(fields: [movementId], references: [id], onDelete: Cascade, onUpdate: Cascade)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Restrict, onUpdate: Cascade)
previousStatus AssetStatus?
newStatus AssetStatus
notes String?
createdAt DateTime @default(now())
@@unique([movementId, assetId])
@@index([assetId, createdAt])
}
// ======================================================
// STOCK ALERTS
// ======================================================
enum StockAlertStatus {
OPEN
ACKNOWLEDGED
RESOLVED
}
enum StockAlertTrigger {
BELOW_MINIMUM
OUT_OF_STOCK
}
model StockAlert {
id String @id @default(uuid(7)) @db.Uuid
itemId String @db.Uuid
item Item @relation(fields: [itemId], references: [id], onDelete: Restrict, onUpdate: Cascade)
trigger StockAlertTrigger
status StockAlertStatus @default(OPEN)
availableStock Int
minimumStock Int
suggestedPurchase Int?
triggeredAt DateTime @default(now())
acknowledgedAt DateTime?
acknowledgedById String? @db.Uuid
acknowledgedBy User? @relation("StockAlertAcknowledgedBy", fields: [acknowledgedById], references: [id], onDelete: SetNull, onUpdate: Cascade)
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([itemId, status])
@@index([status, triggeredAt])
@@index([trigger, triggeredAt])
}
+17
View File
@@ -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)
})
+109
View File
@@ -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,
}
}
}
+40
View File
@@ -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)),
]),
)
}
+177
View File
@@ -0,0 +1,177 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages"
import { getI18n } from "@/i18n/server"
import {
buildCreateAssignmentSchema,
buildReturnAssignmentSchema,
buildUpdateAssignmentSchema,
type CreateAssignmentFormType,
type ReturnAssignmentFormType,
type UpdateAssignmentFormType,
} from "@/schemas/assignment.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
createAssignmentUseCase,
returnAssignmentUseCase,
updateAssignmentUseCase,
} from "@/use-cases/assignment.use-cases"
export async function createAssignment(formData: CreateAssignmentFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = buildCreateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, assetId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const normalizedQuantity = assetId ? 1 : quantity
const result = await createAssignmentUseCase({
...validatedFields.data,
quantity: normalizedQuantity,
lines: [
{
itemId,
quantity: normalizedQuantity,
notes,
},
],
actorId: createdBy,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.createFailure,
}
}
}
export async function updateAssignment(formData: UpdateAssignmentFormType) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = buildUpdateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await updateAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId,
quantity,
notes,
},
],
actorId: createdBy,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: copy.actions.updateFailure,
}
}
}
type ReturnAssignmentActionResult =
| { success: true; message: string }
| { success: false; errors?: Record<string, string[]>; message?: string }
export async function returnAssignment(
formData: ReturnAssignmentFormType,
): Promise<ReturnAssignmentActionResult> {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const userId = await getAuthenticatedUserId()
const validatedFields = buildReturnAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
const result = await returnAssignmentUseCase({
id: validatedFields.data.id,
actorId: userId,
returns: validatedFields.data.returns,
})
if (!result.success) {
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
message: copy.actions.returnFailure,
}
}
revalidatePath("/assignments")
return {
success: true as const,
message: copy.actions.returnSuccess,
}
}
+44
View File
@@ -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 { AuthError } from "next-auth"
import { signIn } from "@/lib/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) { export async function signInAction(values: SignInFormType) {
const { username, password } = values const { email, password } = values
try { try {
await signIn("credentials", { await signIn("credentials", {
username, email,
password, password,
redirect: false, redirect: false,
}) })
+133
View File
@@ -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: {},
}
}
}
+43
View File
@@ -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)),
]),
)
}
+33
View File
@@ -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 { revalidatePath } from "next/cache"
import Papa from "papaparse" import Papa from "papaparse"
import { flattenError } from "zod"
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas" import { type ImportFormType, importSchema } from "@/schemas/import.schema"
import { ImportItem } from "@/lib/types" import type { CreateMovementFormType } from "@/schemas/movement.schema"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import { getAuthenticatedUserId } from "@/services/auth.service"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.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) { export async function importItems(formData: ImportFormType) {
const validatedFields = importSchema.safeParse(formData) const validatedFields = importSchema.safeParse(formData)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
errors: validatedFields.error.flatten().fieldErrors, errors: flattenError(validatedFields.error).fieldErrors,
} }
} }
const { file, categoryId } = validatedFields.data const { file, categoryId } = validatedFields.data
const userId = await getAuthenticatedUserId()
if (!file) { if (!file) {
return { return {
@@ -47,7 +58,7 @@ export async function importItems(formData: ImportFormType) {
if (papaErrors.length > 0) { if (papaErrors.length > 0) {
return { return {
errors: { 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, category,
deliveryNote, deliveryNote,
assigned, assigned,
username,
firstName, firstName,
lastName, lastName,
} = row } = row
@@ -155,7 +165,7 @@ export async function importItems(formData: ImportFormType) {
importErrors.push(`Row ${index + 2}: Category or categoryId is required`) 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`) 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`) 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") { if (firstName && typeof firstName !== "string") {
importErrors.push(`Row ${index + 2}: First name must be a 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() || "", categoryId: categoryId ? categoryId : row.categoryId?.trim() || "",
category: row.category?.trim() || "", category: row.category?.trim() || "",
deliveryNote: row.deliveryNote?.trim() || "", deliveryNote: row.deliveryNote?.trim() || "",
assigned: row.assigned?.trim() === "true" ? true : false, assigned: row.assigned?.trim() === "true",
username: row.username?.trim() || "",
firstName: row.firstName?.trim() || "", firstName: row.firstName?.trim() || "",
lastName: row.lastName?.trim() || "", lastName: row.lastName?.trim() || "",
}) })
@@ -218,17 +223,16 @@ export async function importItems(formData: ImportFormType) {
category, category,
deliveryNote, deliveryNote,
assigned, assigned,
username,
firstName, firstName,
lastName, lastName,
} = item } = item
// Reset variables at the beginning of each iteration // Reset variables at the beginning of each iteration
let newItem let newItem: Item | null = null
let newAsset let newAsset: Asset | null = null
let newCategory let newCategory: Category | null = null
let newRecipient let newPerson: Person | null = null
let newAssignment let newAssignment: Assignment | null = null
const existingCategory = categoryId const existingCategory = categoryId
? await CategoryService.findById(categoryId) ? await CategoryService.findById(categoryId)
@@ -244,7 +248,13 @@ export async function importItems(formData: ImportFormType) {
if (!existingItem) { if (!existingItem) {
newItem = await ItemService.create({ newItem = await ItemService.create({
sku: name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
name, name,
trackingType: "QUANTITY",
stock: assigned ? 0 : stock || 0, stock: assigned ? 0 : stock || 0,
category: { category: {
connect: { id: categoryId ? categoryId : newCategory?.id || "" }, connect: { id: categoryId ? categoryId : newCategory?.id || "" },
@@ -279,22 +289,23 @@ export async function importItems(formData: ImportFormType) {
} }
if (assigned && firstName && lastName) { if (assigned && firstName && lastName) {
const finalUsername = const existingPerson = firstName
username || `${firstName.toLowerCase()[0]}${lastName.toLowerCase()}` ? await PersonService.findAllPaginated({
const existingRecipient = search: firstName,
await RecipientService.findByUsername(finalUsername) page: 0,
pageSize: 1,
})
: null
if (!existingRecipient) { if (!existingPerson || existingPerson.data.length === 0) {
newRecipient = await RecipientService.create({ newPerson = await PersonService.create({
username: finalUsername,
firstName, firstName,
lastName, lastName,
email: undefined, email: undefined,
phone: "", phone: "",
department: "OTHER",
}) })
} else { } else {
newRecipient = existingRecipient newPerson = existingPerson.data[0]
} }
newAssignment = await AssignmentService.create({ newAssignment = await AssignmentService.create({
@@ -302,28 +313,32 @@ export async function importItems(formData: ImportFormType) {
notes: deliveryNote || "", notes: deliveryNote || "",
itemId: newItem?.id || "", itemId: newItem?.id || "",
assetId: newAsset?.id || "", assetId: newAsset?.id || "",
recipientId: newRecipient?.id || "", personId: newPerson?.id || "",
assignmentDate: new Date(), assignmentDate: new Date(),
createdBy: userId,
}) })
} }
const movementData: any = { const movementData: CreateMovementFormType = {
assetId: newAsset?.id || undefined, assetId: newAsset?.id || undefined,
quantity: stock || 1, quantity: stock || 1,
type: assigned ? "ASSIGNMENT" : "IN", type: assigned ? "ASSIGNMENT" : "IN",
itemId: newItem?.id || undefined, itemId: assigned ? undefined : newItem?.id || undefined,
recipientId: newRecipient?.id || undefined, personId: newPerson?.id || undefined,
} }
if (newAssignment?.id) { if (newAssignment?.id) {
movementData.assignmentId = newAssignment.id movementData.assignmentId = newAssignment.id
} }
if (newRecipient?.id) { if (newPerson?.id) {
movementData.recipientId = newRecipient.id movementData.personId = newPerson.id
} }
await MovementService.create(movementData) await MovementService.create({
...movementData,
userId,
})
} }
revalidatePath("/inventory/items") revalidatePath("/inventory/items")
+135
View File
@@ -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: {},
}
}
}
+40
View File
@@ -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)),
]),
)
}
+192
View File
@@ -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 }
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Dictionary } from "@/i18n/dictionaries"
type PersonActionCopy = Dictionary["inventory"]["people"]["actions"]
type FieldErrors = Record<string, string[]>
const personErrorMessageKeys = {
"Email already exists": "duplicateEmail",
"Team not found": "teamNotFound",
} as const satisfies Record<string, keyof PersonActionCopy>
function isPersonErrorMessage(
message: string,
): message is keyof typeof personErrorMessageKeys {
return message in personErrorMessageKeys
}
function localizePersonMessage(
message: string,
copy: PersonActionCopy,
): string {
if (!isPersonErrorMessage(message)) return message
return copy[personErrorMessageKeys[message]]
}
export function localizePersonFieldErrors(
errors: FieldErrors | undefined,
copy: PersonActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizePersonMessage(message, copy)),
]),
)
}
+143
View File
@@ -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()
}
+38
View File
@@ -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)),
]),
)
}
+168
View File
@@ -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 }
}
}
+91
View File
@@ -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 === "teamId" && message === schemaCopy.teamIdInvalid)
return message
if (field === "email" && message === schemaCopy.emailInvalid)
return message
if (field === "password" && message === schemaCopy.passwordMinLength)
return message
// Action-level messages (like "Email already exists") come from action copy
return localizeUnifiedCreateMessage(message, copy)
}),
]),
)
}
+16 -12
View File
@@ -4,12 +4,16 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { signInAction } from "@/actions/auth.actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { signInAction } from "@/lib/actions/auth.actions" import type { Dictionary } from "@/i18n/dictionaries"
import { SignInFormType, signInSchema } from "@/lib/schemas/auth.schemas" 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 router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") const callbackUrl = searchParams.get("callbackUrl")
@@ -18,7 +22,7 @@ export default function SignInForm() {
const { register, handleSubmit, formState } = useForm<SignInFormType>({ const { register, handleSubmit, formState } = useForm<SignInFormType>({
resolver: zodResolver(signInSchema), resolver: zodResolver(signInSchema),
defaultValues: { defaultValues: {
username: "", email: "",
password: "", password: "",
}, },
}) })
@@ -38,19 +42,19 @@ export default function SignInForm() {
return ( return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
Username {copy.emailLabel}
<input <input
{...register("username")} {...register("email")}
name="username" name="email"
type="text" type="text"
className="border-input w-full rounded-md border-2 p-2" className="border-input w-full rounded-md border-2 p-2"
/> />
{formState.errors.username && ( {formState.errors.email && (
<p className="text-error">{formState.errors.username.message}</p> <p className="text-error">{formState.errors.email.message}</p>
)} )}
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
Password {copy.passwordLabel}
<input <input
{...register("password")} {...register("password")}
name="password" name="password"
@@ -62,7 +66,7 @@ export default function SignInForm() {
)} )}
</label> </label>
{error && <p className="text-error">{error}</p>} {error && <p className="text-error">{error}</p>}
<Button type="submit">Sign In</Button> <Button type="submit">{copy.submitLabel}</Button>
</form> </form>
) )
} }
+16 -3
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import SignInForm from "./_components/login-form" import SignInForm from "./_components/login-form"
@@ -10,15 +12,26 @@ export default async function LoginPage() {
if (session) redirect("/") if (session) redirect("/")
const { dictionary, locale } = await getI18n()
const copy = dictionary.login
return ( return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Sign In</CardTitle> <CardTitle>
<h1>{copy.title}</h1>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<SignInForm /> <SignInForm copy={copy} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -3,11 +3,13 @@ import Link from "next/link"
export default function Card({ export default function Card({
title, title,
total, total,
countLabel,
icon, icon,
href, href,
}: { }: {
title: string title: string
total: number total: number
countLabel: string
icon: React.ReactNode icon: React.ReactNode
href: string href: string
}) { }) {
@@ -18,7 +20,9 @@ export default function Card({
<div className="mr-4">{icon}</div> <div className="mr-4">{icon}</div>
<div> <div>
<h3 className="text-lg font-medium">{title}</h3> <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> </div>
</div> </div>
+20 -8
View File
@@ -1,21 +1,25 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service" import { PersonService } from "@/services/person.service"
import Card from "./_components/card" import Card from "./_components/card"
export default async function Home() { export default async function Home() {
const { dictionary } = await getI18n()
const copy = dictionary.dashboardHome
const totalItems = await ItemService.findAllItemsCount() const totalItems = await ItemService.findAllItemsCount()
const totalAssets = await AssetService.findAllAssetsCount() const totalAssets = await AssetService.findAllAssetsCount()
const totalRecipients = await RecipientService.findAllRecipientsCount() const totalPeople = await PersonService.findAllPeopleCount()
return ( return (
<div className="container mx-auto p-4"> <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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card <Card
title="Total Items" title={copy.cards.items.title}
total={totalItems} total={totalItems}
countLabel={copy.cards.items.countLabel}
href="/inventory/items" href="/inventory/items"
icon={ icon={
<svg <svg
@@ -24,6 +28,8 @@ export default async function Home() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
role="img"
aria-label="total-items"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -35,8 +41,9 @@ export default async function Home() {
} }
/> />
<Card <Card
title="Total Assets" title={copy.cards.assets.title}
total={totalAssets} total={totalAssets}
countLabel={copy.cards.assets.countLabel}
href="/inventory/assets" href="/inventory/assets"
icon={ icon={
<svg <svg
@@ -45,6 +52,8 @@ export default async function Home() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
role="img"
aria-label="total-assets"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -56,9 +65,10 @@ export default async function Home() {
} }
/> />
<Card <Card
title="Total Recipients" title={copy.cards.people.title}
total={totalRecipients} total={totalPeople}
href="/recipients" countLabel={copy.cards.people.countLabel}
href="/people"
icon={ icon={
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -66,6 +76,8 @@ export default async function Home() {
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
role="img"
aria-label="total-people"
> >
<path <path
strokeLinecap="round" 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 { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { updateAssignment } from "@/actions/assignment.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateAssignment } from "@/lib/actions/assignament.actions"
import { import {
UpdateAssignmentFormType, SubmitButton,
updateAssignmentSchema, type SubmitButtonCopy,
} from "@/lib/schemas/assignment.schemas" } from "@/components/forms/submitButton"
import { Asset, Item, Recipient } from "@/lib/types" 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 { interface Props {
recipients: Recipient[] people: Person[]
items: Item[] items: Item[]
assets: Asset[] assets: Asset[]
initialData: UpdateAssignmentFormType initialData: UpdateAssignmentFormType
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
} }
export default function EditAssignmentForm({ export default function EditAssignmentForm({
recipients, people,
items, items,
assets, assets,
initialData, initialData,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) { }: Props) {
const router = useRouter() const router = useRouter()
const schema = useMemo(
() => buildUpdateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
watch, watch,
} = useForm<UpdateAssignmentFormType>({ } = useForm<UpdateAssignmentFormType>({
resolver: zodResolver(updateAssignmentSchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
...initialData, ...initialData,
id: initialData.id || undefined, id: initialData.id || undefined,
@@ -51,7 +69,7 @@ export default function EditAssignmentForm({
if (response?.errors) { if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach( Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => { (messages) => {
messages.forEach((msg) => toast.error(msg)) messages.forEach((msg) => void toast.error(msg))
}, },
) )
return return
@@ -66,29 +84,29 @@ export default function EditAssignmentForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} /> <input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="recipientId" className="mb-2 block text-lg"> <label htmlFor="personId" className="mb-2 block text-lg">
Recipient {formCopy.personLabel}
</label> </label>
<select <select
id="recipientId" id="personId"
{...register("recipientId")} {...register("personId")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.recipientId ? "border-error" : "" errors.personId ? "border-error" : ""
}`} }`}
> >
{recipients.map((recipient) => ( {people.map((person) => (
<option key={recipient.id} value={recipient.id}> <option key={person.id} value={person.id}>
{recipient.firstName} {recipient.lastName} {person.firstName} {person.lastName}
</option> </option>
))} ))}
</select> </select>
{errors.recipientId && ( {errors.personId && (
<p className="text-error">{errors.recipientId.message}</p> <p className="text-error">{errors.personId.message}</p>
)} )}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg"> <label htmlFor="itemId" className="mb-2 block text-lg">
Item {formCopy.itemLabel}
</label> </label>
<select <select
id="itemId" id="itemId"
@@ -107,7 +125,7 @@ export default function EditAssignmentForm({
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg"> <label htmlFor="assetId" className="mb-2 block text-lg">
Asset {formCopy.assetLabel}
</label> </label>
<select <select
id="assetId" id="assetId"
@@ -116,7 +134,7 @@ export default function EditAssignmentForm({
errors.assetId ? "border-error" : "" errors.assetId ? "border-error" : ""
}`} }`}
> >
<option value="">Select an asset</option> <option value="">{formCopy.assetPlaceholder}</option>
{itemId {itemId
? assets.map((asset) => ( ? assets.map((asset) => (
<option key={asset.id} value={asset.id}> <option key={asset.id} value={asset.id}>
@@ -131,7 +149,7 @@ export default function EditAssignmentForm({
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg"> <label htmlFor="quantity" className="mb-2 block text-lg">
Quantity {formCopy.quantityLabel}
</label> </label>
<input <input
type="number" type="number"
@@ -139,6 +157,7 @@ export default function EditAssignmentForm({
disabled={!itemId || assets.length > 0} disabled={!itemId || assets.length > 0}
min={1} min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0} max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
placeholder={formCopy.quantityPlaceholder}
defaultValue={1} defaultValue={1}
{...register("quantity")} {...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
@@ -150,11 +169,12 @@ export default function EditAssignmentForm({
)} )}
</div> </div>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (assets.length > 0 && !assetId)} disabled={!itemId || (assets.length > 0 && !assetId)}
> >
Update Assignment {formCopy.updateSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -1,40 +1,63 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useMemo } from "react" import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { createAssignment } from "@/actions/assignment.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { createAssignment } from "@/lib/actions/assignament.actions"
import { import {
CreateAssignmentFormType, SubmitButton,
createAssignmentSchema, type SubmitButtonCopy,
} from "@/lib/schemas/assignment.schemas" } from "@/components/forms/submitButton"
import { Asset, Item, Recipient } from "@/lib/types" 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 { interface Props {
recipients: Recipient[] people: Person[]
items: Item[] items: Item[]
assets: Asset[] assets: Asset[]
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
} }
export default function CreateAssignmentForm({ export default function CreateAssignmentForm({
recipients, people,
items, items,
assets, assets,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) { }: Props) {
const searchParams = useSearchParams()
const personId = searchParams.get("personId")
const router = useRouter() const router = useRouter()
const schema = useMemo(
() => buildCreateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
watch, watch,
} = useForm<CreateAssignmentFormType>({ } = useForm<CreateAssignmentFormType>({
resolver: zodResolver(createAssignmentSchema), resolver: zodResolver(schema),
mode: "onSubmit", mode: "onSubmit",
defaultValues: {
personId: personId ?? "",
quantity: 1,
},
}) })
const itemId = watch("itemId") const itemId = watch("itemId")
@@ -45,12 +68,15 @@ export default function CreateAssignmentForm({
}, [assets, itemId]) }, [assets, itemId])
const onSubmit = async (formData: CreateAssignmentFormType) => { const onSubmit = async (formData: CreateAssignmentFormType) => {
const response = await createAssignment(formData) const response = await createAssignment({
...formData,
quantity: itemAssets.length > 0 ? 1 : formData.quantity,
})
if (response?.errors) { if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach( Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => { (messages) => {
messages.forEach((msg) => toast.error(msg)) messages.forEach((msg) => void toast.error(msg))
}, },
) )
return return
@@ -64,30 +90,31 @@ export default function CreateAssignmentForm({
return ( return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="recipientId" className="mb-2 block text-lg"> <label htmlFor="personId" className="mb-2 block text-lg">
Recipient {formCopy.personLabel}
</label> </label>
<select <select
id="recipientId" id="personId"
{...register("recipientId")} disabled={!!personId}
{...register("personId")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.recipientId ? "border-error" : "" errors.personId ? "border-error" : ""
}`} }`}
> >
<option value="">Select a recipient</option> <option value="">{formCopy.personPlaceholder}</option>
{recipients.map((recipient) => ( {people.map((person) => (
<option key={recipient.id} value={recipient.id}> <option key={person.id} value={person.id}>
{recipient.firstName} {recipient.lastName} {person.firstName} {person.lastName}
</option> </option>
))} ))}
</select> </select>
{errors.recipientId && ( {errors.personId && (
<p className="text-error">{errors.recipientId.message}</p> <p className="text-error">{errors.personId.message}</p>
)} )}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg"> <label htmlFor="itemId" className="mb-2 block text-lg">
Item {formCopy.itemLabel}
</label> </label>
<select <select
id="itemId" id="itemId"
@@ -96,7 +123,7 @@ export default function CreateAssignmentForm({
errors.itemId ? "border-error" : "" errors.itemId ? "border-error" : ""
}`} }`}
> >
<option value="">Select an item</option> <option value="">{formCopy.itemPlaceholder}</option>
{items.map((item) => ( {items.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.name} {item.name}
@@ -108,7 +135,7 @@ export default function CreateAssignmentForm({
{itemId && itemAssets.length !== 0 && ( {itemId && itemAssets.length !== 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg"> <label htmlFor="assetId" className="mb-2 block text-lg">
Asset {formCopy.assetLabel}
</label> </label>
<select <select
id="assetId" id="assetId"
@@ -120,7 +147,7 @@ export default function CreateAssignmentForm({
: "" : ""
}`} }`}
> >
<option value="">Select an asset</option> <option value="">{formCopy.assetPlaceholder}</option>
{itemId {itemId
? itemAssets.map((asset) => ( ? itemAssets.map((asset) => (
<option key={asset.id} value={asset.id}> <option key={asset.id} value={asset.id}>
@@ -134,34 +161,32 @@ export default function CreateAssignmentForm({
)} )}
</div> </div>
)} )}
<div className="flex flex-col gap-2"> {itemId && itemAssets.length === 0 && (
<label htmlFor="quantity" className="mb-2 block text-lg"> <div className="flex flex-col gap-2">
Quantity <label htmlFor="quantity" className="mb-2 block text-lg">
</label> {formCopy.quantityLabel}
<input </label>
type="number" <input
id="quantity" type="number"
disabled={!itemId || itemAssets.length > 0} id="quantity"
min={1} min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0} max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
defaultValue={1} placeholder={formCopy.quantityPlaceholder}
{...register("quantity")} {...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${ className="w-full rounded-lg border px-4 py-2"
!itemId || itemAssets.length > 0 />
? "border-gray-300 bg-gray-100" {errors.quantity && (
: "" <p className="text-error">{errors.quantity.message}</p>
}`} )}
/> </div>
{errors.quantity && ( )}
<p className="text-error">{errors.quantity.message}</p>
)}
</div>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (itemAssets.length > 0 && !assetId)} disabled={!itemId || (itemAssets.length > 0 && !assetId)}
> >
Create Assignment {formCopy.createSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -2,51 +2,209 @@
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTransition } from "react" import { useState, useTransition } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { returnAssignment } from "@/actions/assignment.actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { returnAssignment } from "@/lib/actions/assignament.actions" import {
import { ReturnAssignmentFormType } from "@/lib/schemas/assignment.schemas" 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({ export default function ReturnButton({
assignmentId, assignmentId,
ariaLabel,
assignmentLineId,
remainingQuantity,
copy = defaultPartialReturnCopy,
}: { }: {
assignmentId: string assignmentId: string
ariaLabel: string
assignmentLineId?: string
remainingQuantity?: number
copy?: PartialReturnCopy
}) { }) {
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() 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 () => { startTransition(async () => {
const response = await returnAssignment(formData) const response = await returnAssignment(formData)
if (!response.success && response.errors?.id) { if (response.success) {
toast.error(response.errors.id[0]) setOpen(false)
setQuantity(1)
setNotes("")
toast.success(response.message)
router.refresh()
return return
} }
if (response.success) { if (response.errors?.error?.includes("errorConcurrent")) {
toast.success(response.message) setErrorKey("errorConcurrent")
router.refresh()
} else { } else {
toast.error(response.message ?? "Unknown error") setErrorKey("errorGeneric")
} }
}) })
} }
return ( return (
<form action={() => handleReturn({ id: assignmentId })} className="w-full"> <Dialog open={open} onOpenChange={handleOpenChange}>
<input type="hidden" name="id" value={assignmentId} /> <DialogTrigger asChild>
<Button <Button
type="submit" type="button"
className="btn btn-error" className="btn btn-error"
size="icon" size="icon"
variant="outline" variant="outline"
disabled={isPending} aria-label={ariaLabel}
> disabled={isPending}
<ArrowLeft /> >
</Button> <ArrowLeft />
</form> </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>
) )
} }
+18 -3
View File
@@ -1,15 +1,30 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.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" import AssignmentForm from "../_components/new.assignment.form"
export default async function NewAssignmentPage() { export default async function NewAssignmentPage() {
const recipients = await RecipientService.findAll() const people = await PersonService.findAll()
const items = await ItemService.findAllWithStock() const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAllAvailable() const assets = await AssetService.findAllAvailable()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return ( 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>
) )
} }
+44 -15
View File
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import ReturnButton from "./_components/return.button" import ReturnButton from "./_components/return.button"
@@ -15,39 +16,46 @@ export default async function AssignmentsPage(props: {
}> }>
}) { }) {
const searchParams = await props.searchParams 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 search = searchParams?.search || ""
const { data: assignments, totalPages } = const { data: assignments, totalPages } =
await AssignmentService.findAllWithRecipientPaginated({ await AssignmentService.findAllWithPersonPaginated({
page: currentPage, page: currentPage,
search, search,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Assignments" title={copy.list.title}
link="/assignments/new" link="/assignments/new"
searchable={true}
search={search} search={search}
data={assignments} 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 && ( {assignments.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm"> <table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Recipient {copy.list.columns.person}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Item {copy.list.columns.item}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Serial Number {copy.list.columns.serialNumber}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Actions {copy.list.columns.quantity}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -56,11 +64,11 @@ export default async function AssignmentsPage(props: {
<tr key={assignment.id} className="border-b"> <tr key={assignment.id} className="border-b">
<td className="p-4"> <td className="p-4">
<Link <Link
href={`/recipients/${assignment?.recipient?.id}`} href={`/people/${assignment?.person?.id}`}
className="hover:underline" className="hover:underline"
> >
{assignment?.recipient?.firstName}{" "} {assignment?.person?.firstName}{" "}
{assignment?.recipient?.lastName} {assignment?.person?.lastName}
</Link> </Link>
</td> </td>
<td className="p-4"> <td className="p-4">
@@ -72,7 +80,19 @@ export default async function AssignmentsPage(props: {
</Link> </Link>
</td> </td>
<td className="p-4"> <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>
<td className="p-4"> <td className="p-4">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -80,11 +100,20 @@ export default async function AssignmentsPage(props: {
href={`/assignments/${assignment.id}/edit`} href={`/assignments/${assignment.id}/edit`}
passHref passHref
> >
<Button variant="outline"> <Button
variant="outline"
aria-label={copy.list.actions.edit}
>
<Pencil /> <Pencil />
</Button> </Button>
</Link> </Link>
<ReturnButton assignmentId={assignment.id} /> <ReturnButton
assignmentId={assignment.id}
ariaLabel={copy.list.actions.return}
assignmentLineId={assignment.assignmentLineId}
remainingQuantity={assignment.remainingQuantity}
copy={copy.partialReturn}
/>
</div> </div>
</td> </td>
</tr> </tr>
@@ -92,7 +121,7 @@ export default async function AssignmentsPage(props: {
</tbody> </tbody>
<tfoot className="border-t"> <tfoot className="border-t">
<tr> <tr>
<td colSpan={4} className="p-4 text-center text-sm"> <td colSpan={5} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} /> <PaginationButtons totalPages={totalPages} />
</td> </td>
</tr> </tr>
@@ -2,19 +2,23 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChangeEvent } from "react" import type { ChangeEvent } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { importItems } from "@/actions/import.actions"
import { SubmitButton } from "@/components/forms/submitButton" import {
import { importItems } from "@/lib/actions/import.actions" SubmitButton,
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas" type SubmitButtonCopy,
import { CategorySummary } from "@/lib/types" } from "@/components/forms/submitButton"
import { type ImportFormType, importSchema } from "@/schemas/import.schema"
import type { CategorySummary } from "@/types"
export default function ImportForm({ export default function ImportForm({
categories, categories,
submitButtonCopy,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
@@ -96,6 +100,7 @@ export default function ImportForm({
)} )}
</div> </div>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
disabled={!file} disabled={!file}
+7 -2
View File
@@ -2,6 +2,7 @@ import { Download } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ENVIRONMENT } from "@/lib/constants" import { ENVIRONMENT } from "@/lib/constants"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
@@ -9,13 +10,14 @@ import ImportForm from "./_components/import.form"
export default async function ImportPage() { export default async function ImportPage() {
const categories = await CategoryService.findAllWithItemsCount() const categories = await CategoryService.findAllWithItemsCount()
const { dictionary } = await getI18n()
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Mass Import</h1> <h1 className="text-2xl font-bold">Mass Import</h1>
</div> </div>
<div className="flex items-center justify-end gap-4"> <div className="flex items-center justify-end gap-4">
{ENVIRONMENT === "demo" && ( {(ENVIRONMENT === "development" || ENVIRONMENT === "demo") && (
<Link href="/sample_data.csv" download> <Link href="/sample_data.csv" download>
<Button variant="outline"> <Button variant="outline">
<Download /> <Download />
@@ -30,7 +32,10 @@ export default async function ImportPage() {
</Button> </Button>
</Link> </Link>
</div> </div>
<ImportForm categories={categories} /> <ImportForm
categories={categories}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </div>
) )
} }
@@ -1,9 +1,10 @@
"use server" "use server"
import { AssetWithAssignment } from "@/lib/types" import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.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" import EditAssetForm from "../../_components/edit.asset.form"
@@ -14,22 +15,28 @@ export default async function EditAssetPage({
}) { }) {
const { assetId } = await params const { assetId } = await params
const items = await ItemService.findAll() const items = await ItemService.findAll()
const recipients = await RecipientService.findAll() const people = await PersonService.findAll()
const asset = await AssetService.findById(assetId) const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
if (!asset) { if (!asset) {
return <div>Asset not found</div> return <div>{copy.edit.notFound}</div>
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </div>
<EditAssetForm <EditAssetForm
items={items} items={items}
recipients={recipients} people={people}
asset={asset as unknown as AssetWithAssignment} asset={asset as unknown as AssetWithAssignment}
formCopy={copy.form}
schemaCopy={copy.schema}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
) )
@@ -0,0 +1,150 @@
"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 { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { updateAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { updateAssetAction } from "@/lib/actions/asset.actions"
import { import {
UpdateAssetFormType, SubmitButton,
updateAssetSchema, type SubmitButtonCopy,
} from "@/lib/schemas/asset.schemas" } from "@/components/forms/submitButton"
import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
import { import {
buildUpdateAssetSchema,
type UpdateAssetFormType,
} from "@/schemas/asset.schema"
import type {
AssetWithAssignment, AssetWithAssignment,
Item, Item,
Recipient, Person,
UpdateAssetStatus, UpdateAssetStatus,
} from "@/lib/types" } from "@/types"
import type {
AssetFormCopy,
AssetSchemaCopy,
AssetStatusCopy,
} from "./asset.copy"
interface EditAssetFormProps { interface EditAssetFormProps {
asset: AssetWithAssignment asset: AssetWithAssignment
items: Item[] items: Item[]
recipients: Recipient[] people: Person[]
formCopy: AssetFormCopy
schemaCopy: AssetSchemaCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
} }
export default function EditAssetForm({ export default function EditAssetForm({
asset, asset,
items, items,
recipients, people,
formCopy,
schemaCopy,
statusCopy,
submitButtonCopy,
}: EditAssetFormProps) { }: EditAssetFormProps) {
const router = useRouter() const router = useRouter()
const schema = useMemo(() => buildUpdateAssetSchema(schemaCopy), [schemaCopy])
const { const {
register, register,
@@ -39,14 +57,14 @@ export default function EditAssetForm({
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
watch, watch,
} = useForm<UpdateAssetFormType>({ } = useForm<UpdateAssetFormType>({
resolver: zodResolver(updateAssetSchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
id: asset.id, id: asset.id,
itemId: asset.itemId ?? "", itemId: asset.itemId ?? undefined,
serialNumber: asset.serialNumber, serialNumber: asset.serialNumber,
deliveryNote: asset.deliveryNote ?? "", deliveryNote: asset.deliveryNote ?? undefined,
status: asset.status as UpdateAssetStatus, status: asset.status as UpdateAssetStatus,
recipientId: asset.assignment?.recipientId ?? "", personId: asset.assignment?.personId ?? undefined,
}, },
shouldFocusError: true, shouldFocusError: true,
mode: "onSubmit", mode: "onSubmit",
@@ -71,7 +89,7 @@ export default function EditAssetForm({
} }
if (response?.success) { if (response?.success) {
toast.success("Asset updated successfully") toast.success(response.message)
router.push(`/inventory/assets`) router.push(`/inventory/assets`)
} }
} }
@@ -79,15 +97,16 @@ export default function EditAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} /> <input type="hidden" {...register("id")} />
<div> <div>
<label htmlFor="categoryId" className="mb-2 block text-lg"> <label htmlFor="itemId" className="mb-2 block text-lg">
Item {formCopy.itemLabel}
</label> </label>
<select <select
id="itemId" id="itemId"
defaultValue={asset.itemId}
{...register("itemId")} {...register("itemId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a item:</option> <option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => ( {items?.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.name} {item.name}
@@ -100,12 +119,13 @@ export default function EditAssetForm({
</div> </div>
<div> <div>
<label htmlFor="serialNumber" className="mb-2 block text-lg"> <label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number {formCopy.serialNumberLabel}
</label> </label>
<input <input
type="text" type="text"
id="serialNumber" id="serialNumber"
placeholder="Serial number" placeholder={formCopy.serialNumberPlaceholder}
defaultValue={asset.serialNumber}
{...register("serialNumber")} {...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2" 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> <p className="text-error">{errors?.serialNumber?.message}</p>
)} )}
</div> </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> <div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg"> <label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note {formCopy.deliveryNoteLabel}
</label> </label>
<input <input
type="text" type="text"
id="deliveryNote" id="deliveryNote"
placeholder="Delivery note" placeholder={formCopy.deliveryNotePlaceholder}
defaultValue={asset.deliveryNote ?? undefined}
{...register("deliveryNote")} {...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
/> />
@@ -130,17 +246,18 @@ export default function EditAssetForm({
</div> </div>
<div> <div>
<label htmlFor="status" className="mb-2 block text-lg"> <label htmlFor="status" className="mb-2 block text-lg">
Status {formCopy.statusLabel}
</label> </label>
<select <select
id="status" id="status"
defaultValue={asset.status}
{...register("status")} {...register("status")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a status</option> <option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ItemStatus).map((status) => ( {UPDATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{status} {statusCopy[status]}
</option> </option>
))} ))}
</select> </select>
@@ -150,31 +267,33 @@ export default function EditAssetForm({
</div> </div>
{status === "ASSIGNED" && ( {status === "ASSIGNED" && (
<div> <div>
<label htmlFor="recipientId" className="mb-2 block text-lg"> <label htmlFor="personId" className="mb-2 block text-lg">
Recipient {formCopy.personLabel}
</label> </label>
<select <select
id="recipientId" id="personId"
{...register("recipientId")} defaultValue={asset.assignment?.personId ?? undefined}
{...register("personId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a Recipient</option> <option value="">{formCopy.personPlaceholder}</option>
{recipients?.map((recipient) => ( {people?.map((person) => (
<option key={recipient.id} value={recipient.id}> <option key={person.id} value={person.id}>
{recipient.firstName} {recipient.lastName} {person.firstName} {person.lastName}
</option> </option>
))} ))}
</select> </select>
{errors?.recipientId && ( {errors?.personId && (
<p className="text-error">{errors.recipientId.message}</p> <p className="text-error">{errors.personId.message}</p>
)} )}
</div> </div>
)} )}
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Update Asset {formCopy.updateSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -2,25 +2,46 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { createAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { createAssetAction } from "@/lib/actions/asset.actions"
import { import {
CreateAssetFormType, SubmitButton,
createAssetSchema, type SubmitButtonCopy,
} from "@/lib/schemas/asset.schemas" } from "@/components/forms/submitButton"
import { ItemWithoutStock, Recipient } from "@/lib/types" 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 { interface NewAssetFormProps {
items: ItemWithoutStock[] 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 router = useRouter()
const schema = useMemo(() => buildCreateAssetSchema(schemaCopy), [schemaCopy])
const { const {
register, register,
@@ -29,7 +50,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
watch, watch,
} = useForm<CreateAssetFormType>({ } = useForm<CreateAssetFormType>({
resolver: zodResolver(createAssetSchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
status: "AVAILABLE", status: "AVAILABLE",
}, },
@@ -56,7 +77,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
} }
if (response?.success) { if (response?.success) {
toast.success("Asset created successfully") toast.success(response.message)
router.push(`/inventory/assets`) 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)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} /> <input type="hidden" {...register("id")} />
<div> <div>
<label htmlFor="categoryId" className="mb-2 block text-lg"> <label htmlFor="itemId" className="mb-2 block text-lg">
Item {formCopy.itemLabel}
</label> </label>
<select <select
id="itemId" id="itemId"
{...register("itemId")} {...register("itemId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a item:</option> <option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => ( {items?.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.name} {item.name}
@@ -85,12 +106,12 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div> </div>
<div> <div>
<label htmlFor="serialNumber" className="mb-2 block text-lg"> <label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number {formCopy.serialNumberLabel}
</label> </label>
<input <input
type="text" type="text"
id="serialNumber" id="serialNumber"
placeholder="Serial number" placeholder={formCopy.serialNumberPlaceholder}
{...register("serialNumber")} {...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2" 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> <p className="text-error">{errors?.serialNumber?.message}</p>
)} )}
</div> </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> <div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg"> <label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note {formCopy.deliveryNoteLabel}
</label> </label>
<input <input
type="text" type="text"
id="deliveryNote" id="deliveryNote"
placeholder="Delivery note" placeholder={formCopy.deliveryNotePlaceholder}
{...register("deliveryNote")} {...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
/> />
@@ -115,17 +225,17 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div> </div>
<div> <div>
<label htmlFor="status" className="mb-2 block text-lg"> <label htmlFor="status" className="mb-2 block text-lg">
Status {formCopy.statusLabel}
</label> </label>
<select <select
id="status" id="status"
{...register("status")} {...register("status")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a status</option> <option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ItemStatus).map((status) => ( {CREATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{status} {statusCopy[status]}
</option> </option>
))} ))}
</select> </select>
@@ -135,31 +245,32 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div> </div>
{status === "ASSIGNED" && ( {status === "ASSIGNED" && (
<div> <div>
<label htmlFor="recipientId" className="mb-2 block text-lg"> <label htmlFor="personId" className="mb-2 block text-lg">
Recipient {formCopy.personLabel}
</label> </label>
<select <select
id="recipientId" id="personId"
{...register("recipientId")} {...register("personId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a Recipient</option> <option value="">{formCopy.personPlaceholder}</option>
{recipients?.map((recipient) => ( {people?.map((person) => (
<option key={recipient.id} value={recipient.id}> <option key={person.id} value={person.id}>
{recipient.firstName} {recipient.lastName} {person.firstName} {person.lastName}
</option> </option>
))} ))}
</select> </select>
{errors?.recipientId && ( {errors?.personId && (
<p className="text-error">{errors.recipientId.message}</p> <p className="text-error">{errors.personId.message}</p>
)} )}
</div> </div>
)} )}
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Create Asset {formCopy.createSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -1,20 +1,30 @@
"use server" "use server"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service" 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" import NewAssetForm from "../_components/new.asset.form"
export default async function NewAssetPage() { export default async function NewAssetPage() {
const items = await ItemService.findAllAssignable() 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </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> </div>
) )
} }
+78 -11
View File
@@ -4,8 +4,32 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" 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: { export default async function AssetsPage(props: {
searchParams?: Promise<{ searchParams?: Promise<{
page?: string page?: string
@@ -13,7 +37,7 @@ export default async function AssetsPage(props: {
}> }>
}) { }) {
const searchParams = await props.searchParams 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 search = searchParams?.search || ""
const { data: assets, totalPages } = const { data: assets, totalPages } =
await AssetService.findAllWithItemAndCategory({ await AssetService.findAllWithItemAndCategory({
@@ -21,19 +45,23 @@ export default async function AssetsPage(props: {
pageSize: 10, pageSize: 10,
search, search,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Assets" title={copy.list.title}
link="/inventory/assets/new" link="/inventory/assets/new"
data={assets} data={assets}
searchable={true}
search={search} search={search}
addLabel={copy.list.addLabel}
/> />
{assets.length === 0 && currentPage === 1 && ( {assets.length === 0 && currentPage === 1 && (
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
No Assets found. {copy.list.empty}
</div> </div>
</div> </div>
)} )}
@@ -43,19 +71,37 @@ export default async function AssetsPage(props: {
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Item Name {copy.list.columns.item}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Category {copy.list.columns.category}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Serial Number {copy.list.columns.serialNumber}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Status {copy.list.columns.assetTag}
</th> </th>
<th scope="col" className="p-4"> <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> </th>
</tr> </tr>
</thead> </thead>
@@ -65,10 +111,31 @@ export default async function AssetsPage(props: {
<td className="p-4">{asset.item?.name}</td> <td className="p-4">{asset.item?.name}</td>
<td className="p-4">{asset.item?.category?.name}</td> <td className="p-4">{asset.item?.category?.name}</td>
<td className="p-4">{asset.serialNumber}</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"> <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> <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 /> <Pencil />
</Button> </Button>
</Link> </Link>
@@ -78,7 +145,7 @@ export default async function AssetsPage(props: {
</tbody> </tbody>
<tfoot className="border-t"> <tfoot className="border-t">
<tr> <tr>
<td colSpan={5} className="p-4 text-center text-sm"> <td colSpan={11} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} /> <PaginationButtons totalPages={totalPages} />
</td> </td>
</tr> </tr>
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import EditCategoryForm from "../../_components/edit.category.form" import EditCategoryForm from "../../_components/edit.category.form"
@@ -11,6 +12,8 @@ export default async function EditCategoryPage({
}) { }) {
const { categoryId } = await params const { categoryId } = await params
const category = await CategoryService.findById(categoryId) const category = await CategoryService.findById(categoryId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
if (!category) { if (!category) {
notFound() notFound()
@@ -19,9 +22,14 @@ export default async function EditCategoryPage({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </div>
<EditCategoryForm category={category} /> <EditCategoryForm
category={category}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </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 { useRouter } from "next/navigation"
import { useTransition } from "react" import { useTransition } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { deleteCategoryAction } from "@/actions/category.actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { deleteCategoryAction } from "@/lib/actions/category.actions"
import type { CategoryDeleteCopy } from "./category.copy"
export default function DeleteCategoryButton({ export default function DeleteCategoryButton({
categoryId, categoryId,
copy,
}: { }: {
categoryId: string categoryId: string
copy: CategoryDeleteCopy
}) { }) {
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
@@ -29,7 +32,7 @@ export default function DeleteCategoryButton({
toast.success(response.message) toast.success(response.message)
router.refresh() router.refresh()
} else { } else {
toast.error(response.message ?? "Unknown error") toast.error(response.message ?? copy.unknownError)
} }
}) })
} }
@@ -43,6 +46,7 @@ export default function DeleteCategoryButton({
size="icon" size="icon"
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
> >
<Trash /> <Trash />
</Button> </Button>
@@ -2,23 +2,37 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { updateCategoryAction } from "@/actions/category.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateCategoryAction } from "@/lib/actions/category.actions"
import { import {
UpdateCategoryFormType, SubmitButton,
updateCategorySchema, type SubmitButtonCopy,
} from "@/lib/schemas/category.schemas" } from "@/components/forms/submitButton"
import { CategorySummary } from "@/lib/types" import {
buildUpdateCategorySchema,
type UpdateCategoryFormType,
} from "@/schemas/category.schema"
import type { CategorySummary } from "@/types"
import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function EditCategoryForm({ export default function EditCategoryForm({
category, category,
formCopy,
schemaCopy,
submitButtonCopy,
}: { }: {
category: CategorySummary category: CategorySummary
formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(
() => buildUpdateCategorySchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
@@ -26,7 +40,7 @@ export default function EditCategoryForm({
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateCategoryFormType>({ } = useForm<UpdateCategoryFormType>({
resolver: zodResolver(updateCategorySchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
id: category.id, id: category.id,
name: category.name, name: category.name,
@@ -60,12 +74,12 @@ export default function EditCategoryForm({
<input type="hidden" {...register("id")} /> <input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Category name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : "" errors.name ? "border-error" : ""
@@ -74,10 +88,11 @@ export default function EditCategoryForm({
{errors.name && <p className="text-error">{errors.name.message}</p>} {errors.name && <p className="text-error">{errors.name.message}</p>}
</div> </div>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Update Category {formCopy.updateSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -2,18 +2,34 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { createCategoryAction } from "@/actions/category.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { createCategoryAction } from "@/lib/actions/category.actions"
import { import {
CreateCategoryFormType, SubmitButton,
createCategorySchema, type SubmitButtonCopy,
} from "@/lib/schemas/category.schemas" } 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 router = useRouter()
const schema = useMemo(
() => buildCreateCategorySchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
@@ -21,7 +37,7 @@ export default function NewCategoryForm() {
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateCategoryFormType>({ } = useForm<CreateCategoryFormType>({
resolver: zodResolver(createCategorySchema), resolver: zodResolver(schema),
}) })
const onSubmit = async (formData: CreateCategoryFormType) => { const onSubmit = async (formData: CreateCategoryFormType) => {
@@ -50,12 +66,12 @@ export default function NewCategoryForm() {
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Category name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : "" errors.name ? "border-error" : ""
@@ -64,10 +80,11 @@ export default function NewCategoryForm() {
{errors.name && <p className="text-error">{errors.name.message}</p>} {errors.name && <p className="text-error">{errors.name.message}</p>}
</div> </div>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Create Category {formCopy.createSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -1,12 +1,21 @@
import { getI18n } from "@/i18n/server"
import NewCategoryForm from "../_components/new.category.form" 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </div>
<NewCategoryForm /> <NewCategoryForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </div>
) )
} }
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import DeleteCategoryButton from "./_components/delete.category.button" import DeleteCategoryButton from "./_components/delete.category.button"
@@ -15,7 +16,7 @@ export default async function Items(props: {
}> }>
}) { }) {
const searchParams = await props.searchParams 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 search = searchParams?.search || ""
const { data: categories, totalPages } = const { data: categories, totalPages } =
await CategoryService.findAllWithItemsCountPaginated({ await CategoryService.findAllWithItemsCountPaginated({
@@ -23,18 +24,23 @@ export default async function Items(props: {
pageSize: 10, pageSize: 10,
search, search,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Categories" title={copy.list.title}
addLabel={copy.list.addLabel}
link="/inventory/categories/new" link="/inventory/categories/new"
data={categories} data={categories}
searchable={true}
search={search}
/> />
{categories.length === 0 && currentPage === 1 && ( {categories.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
No Categories found. {copy.list.empty}
</div> </div>
</div> </div>
)} )}
@@ -44,13 +50,13 @@ export default async function Items(props: {
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Name {copy.list.columns.name}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Items {copy.list.columns.items}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Actions {copy.list.columns.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -68,12 +74,16 @@ export default async function Items(props: {
className="btn btn-primary" className="btn btn-primary"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={copy.list.actions.edit}
> >
<Pencil /> <Pencil />
</Button> </Button>
</Link> </Link>
{category._count.items === 0 && ( {category._count.items === 0 && (
<DeleteCategoryButton categoryId={category.id} /> <DeleteCategoryButton
categoryId={category.id}
copy={copy.delete}
/>
)} )}
</td> </td>
</tr> </tr>
@@ -1,3 +1,4 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
@@ -11,22 +12,30 @@ export default async function AddItem({
const { itemId } = await params const { itemId } = await params
const categories = await CategoryService.findAll() const categories = await CategoryService.findAll()
const item = await ItemService.findByIdWithAssetCount(itemId) const item = await ItemService.findByIdWithAssetCount(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
if (!item) { if (!item) {
return <div>Item not found</div> return <div>{copy.edit.notFound}</div>
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{item?._count?.assets && item?._count.assets > 0 && ( {item?._count?.assets && item?._count.assets > 0 && (
<div className="rounded-sm bg-red-100 p-4 text-red-800"> <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>
)} )}
<div className="flex items-center justify-between gap-4"> <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> </div>
<UpdateItemForm categories={categories} item={item} /> <UpdateItemForm
categories={categories}
item={item}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </div>
) )
} }
@@ -1,4 +1,6 @@
import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service" import { MovementService } from "@/services/movement.service"
@@ -12,9 +14,12 @@ export default async function ItemPage({
const item = await ItemService.findByIdWithCategory(itemId) const item = await ItemService.findByIdWithCategory(itemId)
const assets = await AssetService.findByItemId(itemId) const assets = await AssetService.findByItemId(itemId)
const movements = await MovementService.findAllByItemId(itemId) const movements = await MovementService.findAllByItemId(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items.detail
const movementCopy = dictionary.inventory.movements
if (!item) { if (!item) {
return <div>Item not found</div> return <div>{copy.notFound}</div>
} }
return ( return (
@@ -26,11 +31,11 @@ export default async function ItemPage({
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"> <div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex justify-between"> <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> <span>{item.category.name}</span>
</div> </div>
<div className="flex justify-between"> <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> <span>{item.stock}</span>
</div> </div>
</div> </div>
@@ -74,7 +79,7 @@ export default async function ItemPage({
{movements?.length > 0 && ( {movements?.length > 0 && (
<Card className="rounded-sm shadow-none"> <Card className="rounded-sm shadow-none">
<CardHeader> <CardHeader>
<CardTitle>Movements</CardTitle> <CardTitle>{movementCopy.snippet.title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{movements.map((movement) => ( {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" className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
> >
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Type</span> <span className="text-gray-600">
<span>{movement.type}</span> {movementCopy.snippet.labels.type}
</span>
<span>
{formatMovementType(
movement.type,
movementCopy.types,
movementCopy.fallback,
)}
</span>
</div> </div>
<div className="flex justify-between"> <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> <span>{movement.quantity}</span>
</div> </div>
</div> </div>
@@ -4,11 +4,18 @@ import { Trash } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTransition } from "react" import { useTransition } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { deleteItemAction } from "@/actions/item.actions"
import { Button } from "@/components/ui/button" 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 router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
@@ -25,7 +32,7 @@ export default function DeleteItemButton({ itemId }: { itemId: string }) {
toast.success(response.message) toast.success(response.message)
router.refresh() router.refresh()
} else { } 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" size="icon"
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
> >
<Trash /> <Trash />
</Button> </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 }
@@ -1,37 +1,51 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { createItemAction } from "@/actions/item.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { createItemAction } from "@/lib/actions/item.actions"
import { import {
CreateItemFormType, SubmitButton,
createItemSchema, type SubmitButtonCopy,
} from "@/lib/schemas/item.schemas" } from "@/components/forms/submitButton"
import { CategorySummary } from "@/lib/types" import {
buildCreateItemResolver,
buildCreateItemSchema,
type CreateItemData,
type CreateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary } from "@/types"
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
import StockPolicyFields from "./stock-policy-fields"
export default function NewItemForm({ export default function NewItemForm({
categories, categories,
formCopy,
schemaCopy,
submitButtonCopy,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy])
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType>({ } = useForm<CreateItemFormType, unknown, CreateItemData>({
resolver: zodResolver(createItemSchema), resolver: buildCreateItemResolver(schema),
shouldFocusError: true, shouldFocusError: true,
mode: "onSubmit", mode: "onSubmit",
}) })
const onSubmit = async (formData: CreateItemFormType) => { const onSubmit = async (formData: CreateItemData) => {
const response = await createItemAction(formData) const response = await createItemAction(formData)
if (response?.errors) { if (response?.errors) {
@@ -49,7 +63,7 @@ export default function NewItemForm({
if (response?.success) { if (response?.success) {
toast.success(response.message) toast.success(response.message)
router.push("/inventory/items ") router.push("/inventory/items")
} }
} }
@@ -57,27 +71,29 @@ export default function NewItemForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Item name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
/> />
{errors?.name && <p className="text-error">{errors.name.message}</p>} {errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="categoryId" className="mb-2 block text-lg"> <label htmlFor="categoryId" className="mb-2 block text-lg">
Category {formCopy.categoryLabel}
</label> </label>
<select <select
id="categoryId" id="categoryId"
{...register("categoryId")} {...register("categoryId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">Select a category</option> <option value="">{formCopy.categoryPlaceholder}</option>
{categories?.map((category) => ( {categories?.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.name} {category.name}
@@ -85,18 +101,18 @@ export default function NewItemForm({
))} ))}
</select> </select>
{errors?.categoryId && ( {errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p> <p className="text-error">{errors.categoryId.message as string}</p>
)} )}
</div> </div>
<div> <div>
<label htmlFor="stock" className="mb-2 block text-lg"> <label htmlFor="stock" className="mb-2 block text-lg">
Stock {formCopy.stockLabel}
</label> </label>
<input <input
type="number" type="number"
id="stock" id="stock"
pattern="{[0-9]*}" pattern="{[0-9]*}"
placeholder="0" placeholder={formCopy.stockPlaceholder}
min="0" min="0"
{...register("stock")} {...register("stock")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
@@ -113,13 +129,28 @@ export default function NewItemForm({
} }
}} }}
/> />
{errors?.stock && <p className="text-error">{errors.stock.message}</p>} {errors?.stock && (
<p className="text-error">{errors.stock.message as string}</p>
)}
</div> </div>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Create Item {formCopy.createSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -0,0 +1,91 @@
"use client"
import type { FieldErrors, Path, UseFormRegister } from "react-hook-form"
type StockPolicyFieldsCopy = {
title: string
description: string
minStockLabel: string
minStockPlaceholder: string
targetStockLabel: string
targetStockPlaceholder: string
}
type StockPolicyFieldValues = {
minStock?: number | string | null
targetStock?: number | string | null
}
type StockPolicyFieldsProps<TFieldValues extends StockPolicyFieldValues> = {
copy: StockPolicyFieldsCopy
register: UseFormRegister<TFieldValues>
errors?: FieldErrors<TFieldValues>
}
function StockPolicyNumericInput<TFieldValues extends StockPolicyFieldValues>({
id,
label,
placeholder,
error,
register,
}: {
id: "minStock" | "targetStock"
label: string
placeholder: string
error?: string
register: UseFormRegister<TFieldValues>
}) {
return (
<div>
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
id={id}
type="number"
min="0"
step="1"
placeholder={placeholder}
{...register(id as Path<TFieldValues>)}
className="w-full rounded-lg border px-4 py-2"
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
export default function StockPolicyFields<
TFieldValues extends StockPolicyFieldValues,
>({ copy, register, errors }: StockPolicyFieldsProps<TFieldValues>) {
const minStockMessage = errors?.minStock?.message
const targetStockMessage = errors?.targetStock?.message
const minStockError =
typeof minStockMessage === "string" ? minStockMessage : undefined
const targetStockError =
typeof targetStockMessage === "string" ? targetStockMessage : undefined
return (
<section className="rounded-lg border p-4">
<div className="mb-4 space-y-1">
<h2 className="text-lg font-medium">{copy.title}</h2>
<p className="text-muted-foreground text-sm">{copy.description}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StockPolicyNumericInput<TFieldValues>
id="minStock"
label={copy.minStockLabel}
placeholder={copy.minStockPlaceholder}
error={minStockError}
register={register}
/>
<StockPolicyNumericInput<TFieldValues>
id="targetStock"
label={copy.targetStockLabel}
placeholder={copy.targetStockPlaceholder}
error={targetStockError}
register={register}
/>
</div>
</section>
)
}
@@ -1,26 +1,40 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { updateItemAction } from "@/actions/item.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateItemAction } from "@/lib/actions/item.actions"
import { import {
UpdateItemFormType, SubmitButton,
updateItemSchema, type SubmitButtonCopy,
} from "@/lib/schemas/item.schemas" } from "@/components/forms/submitButton"
import { CategorySummary, ItemWithAssetCount } from "@/lib/types" import {
buildUpdateItemResolver,
buildUpdateItemSchema,
type UpdateItemData,
type UpdateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary, ItemWithAssetCount } from "@/types"
import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
import StockPolicyFields from "./stock-policy-fields"
export default function UpdateItemForm({ export default function UpdateItemForm({
categories, categories,
item, item,
formCopy,
schemaCopy,
submitButtonCopy,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
item: ItemWithAssetCount item: ItemWithAssetCount
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy])
const isDisabled = !!item?._count.assets && item?._count.assets > 0 const isDisabled = !!item?._count.assets && item?._count.assets > 0
@@ -29,19 +43,21 @@ export default function UpdateItemForm({
handleSubmit, handleSubmit,
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType>({ } = useForm<UpdateItemFormType, unknown, UpdateItemData>({
resolver: zodResolver(updateItemSchema), resolver: buildUpdateItemResolver(schema),
defaultValues: { defaultValues: {
id: item?.id, id: item?.id,
name: item?.name, name: item?.name,
categoryId: item?.category.id, categoryId: item?.category.id,
stock: item?.stock, stock: item?.stock,
minStock: item?.minStock ?? undefined,
targetStock: item?.targetStock ?? undefined,
}, },
shouldFocusError: true, shouldFocusError: true,
mode: "onSubmit", mode: "onSubmit",
}) })
const onSubmit = async (formData: UpdateItemFormType) => { const onSubmit = async (formData: UpdateItemData) => {
const response = await updateItemAction(formData) const response = await updateItemAction(formData)
if (response?.errors) { if (response?.errors) {
@@ -59,7 +75,7 @@ export default function UpdateItemForm({
if (response?.success) { if (response?.success) {
toast.success(response.message) toast.success(response.message)
router.push("/inventory/items ") router.push("/inventory/items")
} }
} }
@@ -68,20 +84,22 @@ export default function UpdateItemForm({
{item?.id && <input type="hidden" name="id" value={item.id} />} {item?.id && <input type="hidden" name="id" value={item.id} />}
<div> <div>
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Item name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className={`w-full rounded-lg border px-4 py-2`} className={`w-full rounded-lg border px-4 py-2`}
/> />
{errors?.name && <p className="text-error">{errors.name.message}</p>} {errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="categoryId" className="mb-2 block text-lg"> <label htmlFor="categoryId" className="mb-2 block text-lg">
Category {formCopy.categoryLabel}
</label> </label>
<select <select
id="categoryId" id="categoryId"
@@ -89,7 +107,7 @@ export default function UpdateItemForm({
{...register("categoryId")} {...register("categoryId")}
className={`w-full rounded-lg border px-4 py-2`} className={`w-full rounded-lg border px-4 py-2`}
> >
<option value="">Select a category</option> <option value="">{formCopy.categoryPlaceholder}</option>
{categories?.map((category) => ( {categories?.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.name} {category.name}
@@ -97,18 +115,18 @@ export default function UpdateItemForm({
))} ))}
</select> </select>
{errors?.categoryId && ( {errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p> <p className="text-error">{errors.categoryId.message as string}</p>
)} )}
</div> </div>
<div> <div>
<label htmlFor="stock" className="mb-2 block text-lg"> <label htmlFor="stock" className="mb-2 block text-lg">
Stock {formCopy.stockLabel}
</label> </label>
<input <input
type="number" type="number"
id="stock" id="stock"
pattern="{[0-9]*}" pattern="{[0-9]*}"
placeholder="0" placeholder={formCopy.stockPlaceholder}
min={item.stock} min={item.stock}
disabled={isDisabled} disabled={isDisabled}
{...register("stock")} {...register("stock")}
@@ -128,13 +146,28 @@ export default function UpdateItemForm({
} }
}} }}
/> />
{errors?.stock && <p className="text-error">{errors.stock.message}</p>} {errors?.stock && (
<p className="text-error">{errors.stock.message as string}</p>
)}
</div> </div>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton <SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Update Item {formCopy.updateSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -1,16 +1,24 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import NewItemForm from "../_components/new.item.form" import NewItemForm from "../_components/new.item.form"
export default async function NewItemPage() { export default async function NewItemPage() {
const categories = await CategoryService.findAll() const categories = await CategoryService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </div>
<NewItemForm categories={categories} /> <NewItemForm
categories={categories}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </div>
) )
} }
+44 -12
View File
@@ -4,10 +4,24 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import DeleteItemButton from "./_components/delete.item.button" import DeleteItemButton from "./_components/delete.item.button"
function formatStockPolicy(
item: { minStock: number | null; targetStock: number | null },
copy: { configured: string; none: string },
) {
if (item.minStock === null || item.targetStock === null) {
return copy.none
}
return copy.configured
.replace("{min}", String(item.minStock))
.replace("{target}", String(item.targetStock))
}
export default async function ItemsPage(props: { export default async function ItemsPage(props: {
searchParams?: Promise<{ searchParams?: Promise<{
page?: string page?: string
@@ -15,26 +29,30 @@ export default async function ItemsPage(props: {
}> }>
}) { }) {
const searchParams = await props.searchParams 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 search = searchParams?.search || ""
const { data: items, totalPages } = await ItemService.findAllWithAssetCount({ const { data: items, totalPages } = await ItemService.findAllWithAssetCount({
page: currentPage, page: currentPage,
pageSize: 10, pageSize: 10,
search, search,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Items" title={copy.list.title}
link="/inventory/items/new" link="/inventory/items/new"
addLabel={copy.list.addLabel}
data={items} data={items}
searchable={true}
search={search} search={search}
/> />
{items.length === 0 && currentPage === 1 && ( {items.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
No items found. {copy.list.empty}
</div> </div>
</div> </div>
)} )}
@@ -44,19 +62,22 @@ export default async function ItemsPage(props: {
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Name {copy.list.columns.name}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Category {copy.list.columns.category}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Assets {copy.list.columns.assets}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Stock {copy.list.columns.stock}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Actions {copy.list.columns.stockPolicy}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -67,19 +88,30 @@ export default async function ItemsPage(props: {
<td className="p-4">{item.category.name}</td> <td className="p-4">{item.category.name}</td>
<td className="p-4">{item._count.assets}</td> <td className="p-4">{item._count.assets}</td>
<td className="p-4">{item.stock}</td> <td className="p-4">{item.stock}</td>
<td className="p-4">
{formatStockPolicy(item, copy.list.stockPolicy)}
</td>
<td className="flex items-center gap-2 p-4"> <td className="flex items-center gap-2 p-4">
<Link href={`/inventory/items/${item.id}`} passHref> <Link href={`/inventory/items/${item.id}`} passHref>
<Button variant="outline" size="icon"> <Button
variant="outline"
size="icon"
aria-label={copy.list.actions.view}
>
<Eye /> <Eye />
</Button> </Button>
</Link> </Link>
<Link href={`/inventory/items/${item.id}/edit`} passHref> <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 /> <Pencil />
</Button> </Button>
</Link> </Link>
{item._count.assets === 0 && item.stock === 0 && ( {item._count.assets === 0 && item.stock === 0 && (
<DeleteItemButton itemId={item.id} /> <DeleteItemButton itemId={item.id} copy={copy.delete} />
)} )}
</td> </td>
</tr> </tr>
@@ -87,7 +119,7 @@ export default async function ItemsPage(props: {
</tbody> </tbody>
<tfoot className="border-t"> <tfoot className="border-t">
<tr> <tr>
<td colSpan={5} className="p-4 text-center text-sm"> <td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} /> <PaginationButtons totalPages={totalPages} />
</td> </td>
</tr> </tr>
+4 -1
View File
@@ -3,15 +3,18 @@ import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar" import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar" import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar" import { SidebarProvider } from "@/components/ui/sidebar"
import { getI18n } from "@/i18n/server"
export default async function LayoutDashboard({ export default async function LayoutDashboard({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const { dictionary } = await getI18n()
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar copy={dictionary.layout.sidebar} />
<main className="w-full"> <main className="w-full">
<Navbar /> <Navbar />
<div className="flex-1 p-6">{children}</div> <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
}
+29 -14
View File
@@ -1,62 +1,77 @@
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { getI18n } from "@/i18n/server"
import { formatDate } from "@/lib/utils" import { formatDate } from "@/lib/utils"
import { MovementService } from "@/services/movement.service" import { MovementService } from "@/services/movement.service"
import { formatMovementType } from "./movement.copy"
export default async function MovementsPage(props: { export default async function MovementsPage(props: {
searchParams?: Promise<{ searchParams?: Promise<{
page?: string page?: string
}> }>
}) { }) {
const searchParams = await props.searchParams 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({ const { data: movements, totalPages } = await MovementService.findAll({
page: currentPage, page: currentPage,
pageSize: 12, pageSize: 12,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.movements
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between 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> </div>
{movements.length === 0 && <div>No movements found</div>} {movements.length === 0 && <div>{copy.list.empty}</div>}
{movements.length > 0 && ( {movements.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm"> <table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Type {copy.list.columns.type}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Item {copy.list.columns.item}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Serial Number {copy.list.columns.serialNumber}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Quantity {copy.list.columns.quantity}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Recipient {copy.list.columns.person}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Date {copy.list.columns.date}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{movements.map((movement) => ( {movements.map((movement) => (
<tr key={movement.id} className="border-b"> <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"> <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>
<td className="p-4">{movement.quantity}</td> <td className="p-4">{movement.quantity}</td>
<td className="p-4"> <td className="p-4">
{movement?.recipient?.firstName || "-"}{" "} {movement?.person
{movement?.recipient?.lastName || "-"} ? `${movement.person.firstName} ${movement.person.lastName}`
: copy.fallback.missingValue}
</td> </td>
<td className="p-4">{formatDate(movement.createdAt)}</td> <td className="p-4">{formatDate(movement.createdAt)}</td>
</tr> </tr>
@@ -0,0 +1,38 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import EditPersonForm from "../../_components/people/edit.person.form"
export default async function PersonEditPage({
params,
}: {
params: Promise<{ personId: string }>
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const teams = await listTeamsUseCase()
if (!person) {
return <div>{personCopy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{personCopy.edit.title}</h1>
</div>
<EditPersonForm
person={person}
formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
}
@@ -0,0 +1,107 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "../_components/people/user.copy"
export default async function PersonInfoPage({
params,
}: {
params: Promise<{ personId: string }>
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const assignmentCopy = dictionary.inventory.assignments
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const assignments = await AssignmentService.findAllByPerson(personId)
if (!person) {
return <div>{copy.detail.notFound}</div>
}
return (
<div className="grid gap-6">
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{`${person.firstName} ${person.lastName}`}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">{copy.detail.labels.email}</span>
<span>{person.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{copy.detail.labels.phone}</span>
<span>{person.phone}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{copy.detail.labels.team}</span>
<span>{person.team?.name ?? copy.fallback.noTeam}</span>
</div>
{person.user ? (
<>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.role}
</span>
<span>
{formatUserRole(
person.user.role,
userCopy.roles as UserRoleCopy,
userCopy.fallback as UserFallbackCopy,
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.status}
</span>
<span>
{person.user.status === UserStatus.ACTIVE
? userCopy.status.active
: userCopy.status.inactive}
</span>
</div>
</>
) : (
<div className="col-span-2 flex justify-between">
<span className="text-gray-600">{copy.detail.labels.role}</span>
<span>{copy.detail.labels.noUser}</span>
</div>
)}
</div>
</CardContent>
</Card>
{assignments.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{assignmentCopy.list.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-2 text-sm">
{assignments.map((assignment) => (
<div
key={assignment.id}
className="flex w-full justify-between"
>
<span className="text-gray-600">{assignment.item?.name}</span>
<span>{assignment.asset?.serialNumber}</span>
<span>{assignment.quantity || 1}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,265 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updatePersonUserAction } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUnifiedUpdateSchema,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import type { PersonWithUser } from "@/services/person.service"
import type { TeamSummary } from "@/types"
import {
formatUserRole,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
export default function EditPersonForm({
person,
formCopy,
schemaCopy,
roleLabels,
submitButtonCopy,
teams,
}: {
person: PersonWithUser
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
() => buildUnifiedUpdateSchema(schemaCopy),
[schemaCopy],
)
const hasUser = Boolean(person.userId && person.user)
const user = person.user
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UnifiedUpdateFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: person.teamId ?? null,
email: person.email ?? "",
phone: person.phone ?? "",
...(hasUser && user
? { role: user.role, isActive: user.status === "ACTIVE" }
: {}),
},
})
const onSubmit = async (formData: UnifiedUpdateFormType) => {
const response = await updatePersonUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UnifiedUpdateFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/people")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<TextInput
error={errors.firstName?.message}
id="firstName"
label={formCopy.firstNameLabel}
placeholder={formCopy.firstNamePlaceholder}
register={register("firstName")}
/>
<TextInput
error={errors.lastName?.message}
id="lastName"
label={formCopy.lastNameLabel}
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
register={register("teamId")}
teams={teams}
/>
<TextInput
error={errors.email?.message}
id="email"
label={formCopy.emailLabel}
placeholder={formCopy.emailPlaceholder}
register={register("email")}
type="email"
/>
<TextInput
error={errors.phone?.message}
id="phone"
label={formCopy.phoneLabel}
placeholder={formCopy.phonePlaceholder}
register={register("phone")}
/>
{hasUser && (
<section
className="flex flex-col gap-4 border-t pt-4"
aria-labelledby="user-account-heading"
>
<h2 id="user-account-heading" className="text-xl font-semibold">
{formCopy.userAccountHeading}
</h2>
<RoleSelect
register={register("role")}
roleLabel={formCopy.roleLabel}
roleLabels={roleLabels}
/>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("isActive")} />
{formCopy.activeLabel}
</label>
<TextInput
error={errors.password?.message}
id="password"
label={formCopy.newPasswordLabel}
placeholder={formCopy.newPasswordPlaceholder}
register={register("password")}
type="password"
/>
</section>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
}
function TextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({
register,
roleLabel,
roleLabels,
}: {
register: UseFormRegisterReturn
roleLabel: string
roleLabels: UserRoleCopy
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
{roleLabel}
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">{roleLabels.ADMIN}</option>
<option value="MANAGER">{roleLabels.MANAGER}</option>
<option value="STAFF">{roleLabels.STAFF}</option>
<option value="VIEWER">{roleLabels.VIEWER}</option>
</select>
</div>
)
}
function TeamSelect({
error,
formCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
{error && <p className="text-error">{error}</p>}
</div>
)
}
// Re-export for tests that need to verify the data shape passed to this form.
export { formatUserRole }
@@ -0,0 +1,243 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createPersonUserAction } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUnifiedCreateSchema,
type UnifiedCreateFormType,
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
import type { TeamSummary } from "@/types"
import type { UserFormCopy, UserRoleCopy } from "./user.copy"
export default function NewUserForm({
formCopy,
schemaCopy,
roleLabels,
submitButtonCopy,
teams,
}: {
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) {
const router = useRouter()
const schema = useMemo(
() => buildUnifiedCreateSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
watch,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UnifiedCreateFormType>({
resolver: zodResolver(schema),
defaultValues: {
role: "NO_USER",
isActive: true,
},
})
const selectedRole = watch("role")
const showPassword = selectedRole !== "NO_USER"
const onSubmit = async (formData: UnifiedCreateFormType) => {
const response = await createPersonUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UnifiedCreateFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/people")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<UserTextInput
error={errors.firstName?.message}
id="firstName"
label={formCopy.firstNameLabel}
placeholder={formCopy.firstNamePlaceholder}
register={register("firstName")}
/>
<UserTextInput
error={errors.lastName?.message}
id="lastName"
label={formCopy.lastNameLabel}
placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label={formCopy.emailLabel}
placeholder={formCopy.emailPlaceholder}
register={register("email")}
type="email"
/>
<UserTextInput
error={errors.phone?.message}
id="phone"
label={formCopy.phoneLabel}
placeholder={formCopy.phonePlaceholder}
register={register("phone")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TeamSelect
error={errors.teamId?.message}
formCopy={formCopy}
register={register("teamId")}
teams={teams}
/>
<RoleSelect
register={register("role")}
roleLabel={formCopy.roleLabel}
roleLabels={roleLabels}
/>
{showPassword && (
<UserTextInput
error={errors.password?.message}
id="password"
label={formCopy.passwordLabel}
placeholder={formCopy.passwordPlaceholder}
register={register("password")}
type="password"
/>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.createSubmit}
</SubmitButton>
</div>
</div>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({
register,
roleLabel,
roleLabels,
}: {
register: UseFormRegisterReturn
roleLabel: string
roleLabels: UserRoleCopy
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
{roleLabel}
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">{roleLabels.ADMIN}</option>
<option value="MANAGER">{roleLabels.MANAGER}</option>
<option value="STAFF">{roleLabels.STAFF}</option>
<option value="VIEWER">{roleLabels.VIEWER}</option>
<option value="NO_USER">{roleLabels.NO_USER}</option>
</select>
</div>
)
}
function TeamSelect({
error,
formCopy,
register,
teams,
}: {
error?: string
formCopy: UserFormCopy
register: UseFormRegisterReturn
teams: TeamSummary[]
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.teamLabel}
</label>
<select
id="teamId"
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
>
<option value="">{formCopy.teamPlaceholder}</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
{error && <p className="text-error">{error}</p>}
</div>
)
}
@@ -0,0 +1,153 @@
import { Eye, Pencil, UserPlus } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "./user.copy"
export default async function PersonPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: people, totalPages } = await PersonService.findAllPaginated({
page: currentPage,
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const personFallbackCopy = copy.fallback
return (
<div className="flex flex-col gap-4 mt-4">
<PageHeader
title={copy.list.title}
link="/people/new"
addLabel={copy.list.addLabel}
data={people}
searchable={true}
search={search}
/>
{people.length === 0 && <div>{copy.list.empty}</div>}
{people.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
{copy.list.columns.email}
</th>
<th scope="col" className="p-4">
{copy.list.columns.phone}
</th>
<th scope="col" className="p-4">
{copy.list.columns.team}
</th>
<th scope="col" className="p-4">
{copy.list.columns.role}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th>
</tr>
</thead>
<tbody>
{people.map((person) => (
<tr key={person.id} className="border-b">
<td className="p-4">
{`${person.firstName} ${person.lastName}`}
</td>
<td className="p-4">{person.email}</td>
<td className="p-4">{person.phone}</td>
<td className="p-4">
{person.team?.name ?? personFallbackCopy.noTeam}
</td>
<td className="p-4">
{person.user
? formatUserRole(
person.user.role,
userRoleLabels,
userFallbackCopy,
)
: "—"}
</td>
<td className="p-4">
{person.user
? person.user.status === UserStatus.ACTIVE
? userStatusCopy.active
: userStatusCopy.inactive
: "—"}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/people/${person.id}`} passHref>
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.view}
>
<Eye />
</Button>
</Link>
<Link href={`/people/${person.id}/edit`} passHref>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
<Link
href={`/assignments/new?personId=${person.id}`}
passHref
>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<UserPlus />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={7} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,6 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
@@ -0,0 +1,19 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type UserFormCopy = Dictionary["admin"]["users"]["form"]
export type UserRoleCopy = Dictionary["admin"]["users"]["roles"]
export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
export type UserResetPasswordCopy =
Dictionary["admin"]["users"]["resetPassword"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatUserRole(
role: string,
roleCopy: UserRoleCopy,
fallbackCopy: UserFallbackCopy,
): string {
return role in roleCopy
? roleCopy[role as keyof UserRoleCopy]
: fallbackCopy.unknownRole
}
@@ -0,0 +1,29 @@
import PageHeader from "@/components/common/pageheader"
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import TeamListTable from "./team.list.table"
export default async function TeamsPage() {
const teams = await listTeamsUseCase()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4 mt-4">
<PageHeader
title={copy.list.title}
link="/people/team/new"
addLabel={copy.list.addLabel}
data={teams}
/>
<TeamListTable
teams={teams}
formCopy={copy.form}
schemaCopy={copy.schema}
listCopy={copy.list}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -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>
)
}
+13
View File
@@ -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
}
+25
View File
@@ -0,0 +1,25 @@
import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import NewPersonForm from "../_components/people/new.person.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const teams = await listTeamsUseCase()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewPersonForm
formCopy={copy.form}
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
roleLabels={copy.roles}
submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getI18n } from "@/i18n/server"
import PersonPage from "./_components/people/page"
import TeamsPage from "./_components/team/page"
export default async function PeoplePage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const teamCopy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4">
<Tabs defaultValue="people">
<TabsList variant="line">
<TabsTrigger value="people">{copy.list.title}</TabsTrigger>
<TabsTrigger value="teams">{teamCopy.list.title}</TabsTrigger>
</TabsList>
<TabsContent value="people">
<PersonPage searchParams={props.searchParams} />
</TabsContent>
<TabsContent value="teams">
<TeamsPage />
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,21 @@
import { getI18n } from "@/i18n/server"
import TeamCreateForm from "../../_components/team/team.create.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.teams
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.list.addLabel}</h1>
</div>
<TeamCreateForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -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>
)
}

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